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/dist/src/compaction.js
CHANGED
|
@@ -5,7 +5,7 @@ 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 } from "./clip.js";
|
|
8
|
+
import { applyClips, clipBudgets, clipFor } from "./clip.js";
|
|
9
9
|
import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, decideCut, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, requestTokens, systemPart, validCutAt } from "./budget.js";
|
|
10
10
|
import { isContextOverflow } from "@earendil-works/pi-ai";
|
|
11
11
|
import { convertToLlm, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
@@ -52,6 +52,10 @@ class CapacityError extends Refusal {
|
|
|
52
52
|
* likely comes from that counting (an upstream behind a router can spend much more context on tool schemas), so the notice says so. */
|
|
53
53
|
export const refusalNotice = (message, factor) => message + "; shorten the input, use a model with a larger window, or lower Pi's compaction.reserveTokens, then retry." +
|
|
54
54
|
(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.` : "");
|
|
55
|
+
/** 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
|
|
56
|
+
* 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. */
|
|
57
|
+
export const overflowNotice = (contextWindow, bound, learned) => `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` +
|
|
58
|
+
(learned ? "; the next request is planned at that." : "; too far out to be a low estimate, so the factor is unchanged.");
|
|
55
59
|
/** 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. */
|
|
56
60
|
export const CALIBRATION_CEILING = 1.5;
|
|
57
61
|
/** How many recent paired replies (and overflow bounds, and the seed while it is among them) the calibration factor is the maximum of. */
|
|
@@ -253,10 +257,14 @@ export async function installCompaction(pi, options = {}) {
|
|
|
253
257
|
return;
|
|
254
258
|
// 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
|
|
255
259
|
// 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.
|
|
260
|
+
// 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
|
|
261
|
+
// with no word from pi-tuck read as a hang: every other refusal says why, and this is the path that skipped it.
|
|
256
262
|
if (overflow) {
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
263
|
+
const bound = (ctx.model.contextWindow + 1) / paired.tokens;
|
|
264
|
+
const learned = bound <= CALIBRATION_CEILING;
|
|
265
|
+
ctx.ui.notify(overflowNotice(ctx.model.contextWindow, bound, learned), "warning");
|
|
266
|
+
if (learned)
|
|
267
|
+
learn(bound);
|
|
260
268
|
return;
|
|
261
269
|
}
|
|
262
270
|
const usage = message.usage;
|
|
@@ -266,11 +274,14 @@ export async function installCompaction(pi, options = {}) {
|
|
|
266
274
|
if (!counts.every(n => Number.isSafeInteger(n) && n >= 0))
|
|
267
275
|
return;
|
|
268
276
|
const input = counts.reduce((a, b) => a + b, 0), ratio = input / paired.tokens;
|
|
269
|
-
|
|
277
|
+
// A request that was answered cannot have held more than the model's window, so a report above it describes something that never happened.
|
|
278
|
+
// 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
|
|
279
|
+
// 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.
|
|
280
|
+
if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio))
|
|
270
281
|
learn(ratio);
|
|
271
282
|
});
|
|
272
283
|
pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
|
|
273
|
-
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); });
|
|
284
|
+
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
|
|
274
285
|
const active = () => options.active?.() ?? true;
|
|
275
286
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
276
287
|
// The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
|
|
@@ -312,8 +323,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
312
323
|
const isNoWork = (message) => ["Already compacted", "Nothing to compact (session too small)"].includes(message?.replace(/^Compaction failed: /, "") ?? "");
|
|
313
324
|
// A missing model is configuration, not capacity.
|
|
314
325
|
const missingModel = () => new Error("No context model for the request budget");
|
|
315
|
-
|
|
316
|
-
let pendingCut;
|
|
326
|
+
let promptCut;
|
|
317
327
|
let failure;
|
|
318
328
|
let pendingBoundaryCommit = false;
|
|
319
329
|
// 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
|
|
@@ -321,19 +331,24 @@ export async function installCompaction(pi, options = {}) {
|
|
|
321
331
|
const clips = new Map();
|
|
322
332
|
const fail = (error, ctx) => {
|
|
323
333
|
recorder.stop("native-attempt-failed");
|
|
324
|
-
|
|
334
|
+
// 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
|
|
335
|
+
// means for the session, anything else as it is (an attempt that ended with no word looked like a hang).
|
|
336
|
+
const notify = !failure;
|
|
325
337
|
// A CommitFault stays whatever fails after it.
|
|
326
338
|
if (!(failure instanceof CommitFault))
|
|
327
339
|
failure = error instanceof Error ? error : new Error(String(error));
|
|
328
340
|
pendingBoundaryCommit = false;
|
|
329
341
|
pending = undefined;
|
|
342
|
+
promptCut = undefined;
|
|
330
343
|
try {
|
|
331
344
|
options.fail?.(error);
|
|
332
345
|
}
|
|
333
346
|
finally {
|
|
334
347
|
try {
|
|
335
348
|
if (notify)
|
|
336
|
-
ctx.ui.notify(refusalNotice(failure.message, factor)
|
|
349
|
+
ctx.ui.notify(failure instanceof CapacityError ? refusalNotice(failure.message, factor) : failure instanceof CommitFault
|
|
350
|
+
? `pi-tuck: ${failure.message}; the session holds a cut its file may lack, so start a new session.`
|
|
351
|
+
: `pi-tuck could not plan this request (${failure.message}); the next prompt is planned afresh.`, "error");
|
|
337
352
|
}
|
|
338
353
|
finally {
|
|
339
354
|
ctx.abort();
|
|
@@ -429,9 +444,15 @@ export async function installCompaction(pi, options = {}) {
|
|
|
429
444
|
recorder.stop("cut-cancelled");
|
|
430
445
|
return { cancel: true };
|
|
431
446
|
}
|
|
432
|
-
// Pi
|
|
433
|
-
|
|
434
|
-
|
|
447
|
+
// Pi checks its own threshold after a prompt's input and before the run starts: the prompt's cut, planned with the prompt counted,
|
|
448
|
+
// is the answer, and it is saved before the first request.
|
|
449
|
+
if (promptCut && !admittedRun && event.reason === "threshold") {
|
|
450
|
+
access();
|
|
451
|
+
const { type: _, ...compaction } = promptCut;
|
|
452
|
+
promptCut = undefined;
|
|
453
|
+
return { compaction };
|
|
454
|
+
}
|
|
455
|
+
const operatorManual = event.reason === "manual";
|
|
435
456
|
if (operatorManual && !admittedRun)
|
|
436
457
|
forgetFailure();
|
|
437
458
|
access();
|
|
@@ -442,19 +463,18 @@ export async function installCompaction(pi, options = {}) {
|
|
|
442
463
|
}
|
|
443
464
|
const { response, room, band } = limits(ctx);
|
|
444
465
|
const retain = operatorManual || admittedRun;
|
|
445
|
-
//
|
|
446
|
-
//
|
|
466
|
+
// A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
|
|
467
|
+
// cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
|
|
447
468
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
448
|
-
const host =
|
|
449
|
-
const mandatory =
|
|
450
|
-
|
|
451
|
-
const before = incoming ? incoming.before : plannerMeasureFor(ctx)(saved);
|
|
469
|
+
const host = hostTokens(ctx);
|
|
470
|
+
const mandatory = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt()) + host;
|
|
471
|
+
const before = plannerMeasureFor(ctx)(saved);
|
|
452
472
|
// Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
|
|
453
473
|
// 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
|
|
454
474
|
// trigger would: nothing below the band, an opportunistic cut inside it, a due cut at or above the room. A refusal is a plain
|
|
455
475
|
// cancel (Pi reports it as aborted), never a failure.
|
|
456
476
|
let thresholdOpportunistic = false;
|
|
457
|
-
if (
|
|
477
|
+
if (event.reason === "threshold") {
|
|
458
478
|
const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
459
479
|
if (gate.kind !== "plan")
|
|
460
480
|
return { cancel: true };
|
|
@@ -463,21 +483,18 @@ export async function installCompaction(pi, options = {}) {
|
|
|
463
483
|
// Pi checks old history before appending a new idle prompt. During an active run/manual cut the latest admitted input stays protected.
|
|
464
484
|
const decision = chooseCut({
|
|
465
485
|
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
|
|
466
|
-
opportunistic:
|
|
467
|
-
unreduced:
|
|
468
|
-
: "Compaction did not reduce context within the estimated request budget",
|
|
486
|
+
opportunistic: thresholdOpportunistic,
|
|
487
|
+
unreduced: "Compaction did not reduce context within the estimated request budget",
|
|
469
488
|
plan: (target) => {
|
|
470
489
|
const reserve = planReserve(target, room, mandatory, ctx);
|
|
471
|
-
// Every cut asked while no run is admitted (
|
|
472
|
-
//
|
|
473
|
-
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual,
|
|
490
|
+
// 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
|
|
491
|
+
// from any run that ended. A retry is inside its run.
|
|
492
|
+
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun);
|
|
474
493
|
return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve }
|
|
475
494
|
: { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
476
495
|
},
|
|
477
496
|
});
|
|
478
497
|
if (decision.kind === "cut") {
|
|
479
|
-
if (incoming)
|
|
480
|
-
incoming.proposed = true;
|
|
481
498
|
return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore, retain, operatorManual, event.preparation.previousSummary, { response, host, mandatory, target: decision.target, margin: room - band }) };
|
|
482
499
|
}
|
|
483
500
|
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
@@ -499,12 +516,23 @@ export async function installCompaction(pi, options = {}) {
|
|
|
499
516
|
}
|
|
500
517
|
});
|
|
501
518
|
pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
|
|
502
|
-
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"; });
|
|
503
|
-
// The
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
519
|
+
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"; });
|
|
520
|
+
// 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.
|
|
521
|
+
const cutSession = (ctx, cut) => {
|
|
522
|
+
const header = ctx.sessionManager.getHeader();
|
|
523
|
+
if (!header)
|
|
524
|
+
throw new Error("Missing native session header for the prompt's cut");
|
|
525
|
+
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
526
|
+
preview.appendCompaction(cut.summary, cut.firstKeptEntryId, 0, cut.details, true);
|
|
527
|
+
return preview;
|
|
528
|
+
};
|
|
529
|
+
const withCut = (ctx, cut) => cutSession(ctx, cut).buildSessionProjection().messages;
|
|
530
|
+
// 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
|
|
531
|
+
// 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
|
|
532
|
+
// keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
|
|
533
|
+
// Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
|
|
507
534
|
const preflight = async (ctx, incomingPart, observedPrompt, tools) => {
|
|
535
|
+
promptCut = undefined;
|
|
508
536
|
if (!active())
|
|
509
537
|
return;
|
|
510
538
|
access();
|
|
@@ -512,55 +540,42 @@ export async function installCompaction(pi, options = {}) {
|
|
|
512
540
|
return;
|
|
513
541
|
if (!ctx.model)
|
|
514
542
|
throw missingModel();
|
|
515
|
-
const { room, band } = limits(ctx);
|
|
516
|
-
// Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context
|
|
517
|
-
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
const before = measureFor(ctx)(saved);
|
|
523
|
-
return { incoming: incomingPart + system, host, before, mandatory: incomingPart + system + host };
|
|
524
|
-
};
|
|
525
|
-
const first = snapshot(), total = first.before + first.mandatory;
|
|
526
|
-
if (!fits(first.incoming, room, ctx))
|
|
543
|
+
const { response, room, band } = limits(ctx);
|
|
544
|
+
// Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context guard's responsibility.
|
|
545
|
+
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
546
|
+
const system = systemPart(observedPrompt, toolShapes(tools), saved, ctx.getSystemPrompt());
|
|
547
|
+
const host = hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
|
|
548
|
+
const incoming = incomingPart + system, mandatory = incoming + host;
|
|
549
|
+
if (!fits(incoming, room, ctx))
|
|
527
550
|
throw new CapacityError("Incoming Pi prompt and response reserve cannot fit the estimated request budget");
|
|
528
551
|
// A cut removes only conversation. If the mandatory request, host input included, does not fit by itself, no cut can
|
|
529
552
|
// help: leave it to the complete-context guard, which fails closed.
|
|
530
|
-
const gate = gateCut({ total
|
|
553
|
+
const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
531
554
|
if (gate.kind !== "plan")
|
|
532
555
|
return;
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
}
|
|
556
|
-
finally {
|
|
557
|
-
pendingCut = undefined;
|
|
558
|
-
}
|
|
559
|
-
access();
|
|
560
|
-
// The cut awaited: take the snapshot again. An opportunistic cut never throws; the request is not over the room, and the guard decides.
|
|
561
|
-
const next = snapshot(), after = next.before + next.mandatory, afterFits = fits(after, limits(ctx).room, ctx);
|
|
562
|
-
if (!cut.opportunistic && (after >= total || !afterFits))
|
|
563
|
-
throw new (afterFits ? Refusal : CapacityError)("Incoming Pi preflight compaction did not reduce context within the estimated request budget");
|
|
556
|
+
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
557
|
+
const decision = chooseCut({
|
|
558
|
+
before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), opportunistic: gate.opportunistic,
|
|
559
|
+
unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
|
|
560
|
+
plan: (target) => {
|
|
561
|
+
const reserve = planReserve(target, room, mandatory, ctx);
|
|
562
|
+
// 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
|
|
563
|
+
// there buys nothing the request needed and costs history the next recall would have found.
|
|
564
|
+
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !gate.opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
|
|
565
|
+
if (!draft.compaction)
|
|
566
|
+
return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
567
|
+
const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
|
|
568
|
+
assertNativeToolRounds(messages);
|
|
569
|
+
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
573
|
+
if (decision.kind === "none")
|
|
574
|
+
return;
|
|
575
|
+
if (decision.kind === "fault")
|
|
576
|
+
throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
|
|
577
|
+
const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, "prompt", before, false, false, previousSummary, { response, host, mandatory, target: decision.target, margin: room - band });
|
|
578
|
+
promptCut = { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId, tokensBefore: before, details: compaction.details };
|
|
564
579
|
};
|
|
565
580
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
566
581
|
const prompted = phase === "decided";
|
|
@@ -597,7 +612,13 @@ export async function installCompaction(pi, options = {}) {
|
|
|
597
612
|
const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages) : 0;
|
|
598
613
|
const mandatory = system + host + queued;
|
|
599
614
|
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
615
|
+
// 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
|
|
616
|
+
// request besides the fresh results. Reset per decision, so a retry sizes itself against the cut it is retrying, not an earlier attempt's.
|
|
617
|
+
let previews = [];
|
|
618
|
+
let floorKept;
|
|
600
619
|
const decide = () => {
|
|
620
|
+
previews = [];
|
|
621
|
+
floorKept = undefined;
|
|
601
622
|
const before = measure(saved);
|
|
602
623
|
return decideCut({
|
|
603
624
|
total: before + mandatory, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
|
|
@@ -605,8 +626,15 @@ export async function installCompaction(pi, options = {}) {
|
|
|
605
626
|
plan: (target) => {
|
|
606
627
|
const reserve = planReserve(target, room, mandatory, ctx);
|
|
607
628
|
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
|
|
608
|
-
if (!draft.compaction)
|
|
629
|
+
if (!draft.compaction) {
|
|
630
|
+
// The planner's own floor, so a clip's second budget is measured against what the smallest cut really keeps.
|
|
631
|
+
if (draft.floorMessages && event.toolResults) {
|
|
632
|
+
const fresh = new Set(event.toolResults.map(result => result.toolCallId));
|
|
633
|
+
const kept = measure(draft.floorMessages.filter(message => !(message.role === "toolResult" && fresh.has(message.toolCallId))));
|
|
634
|
+
floorKept = Math.min(floorKept ?? kept, kept);
|
|
635
|
+
}
|
|
609
636
|
return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
637
|
+
}
|
|
610
638
|
const header = ctx.sessionManager.getHeader();
|
|
611
639
|
if (!header)
|
|
612
640
|
throw new Error("Missing native session header at tool-loop boundary");
|
|
@@ -614,27 +642,36 @@ export async function installCompaction(pi, options = {}) {
|
|
|
614
642
|
preview.appendCompaction(draft.compaction.summary, draft.compaction.firstKeptEntryId, before, draft.compaction.details, true);
|
|
615
643
|
const messages = preview.buildSessionProjection().messages;
|
|
616
644
|
assertNativeToolRounds(messages);
|
|
645
|
+
previews.push(messages);
|
|
617
646
|
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
618
647
|
},
|
|
619
648
|
});
|
|
620
649
|
};
|
|
621
650
|
let decision = decide();
|
|
622
|
-
// 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
|
|
623
|
-
//
|
|
624
|
-
//
|
|
651
|
+
// 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
|
|
652
|
+
// 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.
|
|
653
|
+
// clipBudgets states the budgets tried and why they are counted in the estimate's units; `kept` is measured from the previews this decision
|
|
654
|
+
// 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
|
|
655
|
+
// refusal stands.
|
|
625
656
|
if (decision.kind === "fault" && event.toolResults) {
|
|
626
657
|
const branch = ctx.sessionManager.getBranch();
|
|
627
658
|
const entryIdOf = (toolCallId) => branch.findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId)?.id;
|
|
628
|
-
const
|
|
629
|
-
|
|
659
|
+
const fresh = new Set(event.toolResults.map(result => result.toolCallId));
|
|
660
|
+
const kept = previews.length ? Math.min(...previews.map(preview => measure(preview.filter(message => !(message.role === "toolResult" && fresh.has(message.toolCallId)))))) : floorKept;
|
|
661
|
+
for (const budget of clipBudgets(room, factor, mandatory, kept)) {
|
|
662
|
+
const round = clipFor(event.toolResults, entryIdOf, budget);
|
|
663
|
+
if (!round.length)
|
|
664
|
+
continue;
|
|
630
665
|
for (const clip of round)
|
|
631
666
|
clips.set(clip.toolCallId, clip);
|
|
632
667
|
const clippedDecision = decide();
|
|
633
668
|
if (clippedDecision.kind === "fault")
|
|
634
669
|
for (const clip of round)
|
|
635
670
|
clips.delete(clip.toolCallId);
|
|
636
|
-
else
|
|
671
|
+
else {
|
|
637
672
|
decision = clippedDecision;
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
638
675
|
}
|
|
639
676
|
}
|
|
640
677
|
const before = measure(saved);
|
|
@@ -646,12 +683,28 @@ export async function installCompaction(pi, options = {}) {
|
|
|
646
683
|
return { entries: [{ type: "compaction", summary: compaction.summary,
|
|
647
684
|
firstKeptEntryId: compaction.firstKeptEntryId, details: compaction.details }] };
|
|
648
685
|
};
|
|
686
|
+
const atBoundary = (event, ctx, reason, decide) => {
|
|
687
|
+
if (!promptCut)
|
|
688
|
+
return decide ? boundaryCut(event, ctx, reason) : undefined;
|
|
689
|
+
const entry = promptCut;
|
|
690
|
+
promptCut = undefined;
|
|
691
|
+
const preview = cutSession(ctx, entry), view = Object.create(ctx, { sessionManager: { value: preview } });
|
|
692
|
+
const more = decide && !event.entries.length
|
|
693
|
+
? boundaryCut({ ...event, context: { ...event.context, contextMessages: preview.buildSessionProjection().messages } }, view, reason) : undefined;
|
|
694
|
+
const { tokensBefore: _, ...saved } = entry;
|
|
695
|
+
// As for a cut Pi makes (session_compact): the history the cut leaves behind is indexed, once Pi has committed the entries.
|
|
696
|
+
setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
|
|
697
|
+
return { entries: [...event.entries, saved, ...(more?.entries ?? [])] };
|
|
698
|
+
};
|
|
649
699
|
pi.on("turn_end", (event, ctx) => {
|
|
650
700
|
try {
|
|
651
|
-
if (!active() || !admittedRun
|
|
652
|
-
|
|
701
|
+
if (!active() || !admittedRun)
|
|
702
|
+
return;
|
|
703
|
+
const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
|
|
704
|
+
settingsFor().getCompactionSettings(ctx.model).enabled;
|
|
705
|
+
if (!promptCut && !decide)
|
|
653
706
|
return;
|
|
654
|
-
const result =
|
|
707
|
+
const result = atBoundary(event, ctx, "tool-loop", decide);
|
|
655
708
|
if (result)
|
|
656
709
|
pendingBoundaryCommit = true;
|
|
657
710
|
return result;
|
|
@@ -660,12 +713,23 @@ export async function installCompaction(pi, options = {}) {
|
|
|
660
713
|
fail(error, ctx);
|
|
661
714
|
}
|
|
662
715
|
});
|
|
716
|
+
// 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
|
|
717
|
+
// the conversation with the cut applied. Pi sends the saved system message apart from the request's messages, so neither side carries it
|
|
718
|
+
// here. A request that does not carry the saved conversation as it stands cannot take the cut.
|
|
719
|
+
const cutRequest = (messages, ctx, cut) => {
|
|
720
|
+
const conversation = (all) => all.filter(message => message.role !== "system");
|
|
721
|
+
const saved = conversation(ctx.sessionManager.buildSessionProjection().messages);
|
|
722
|
+
for (let at = 0; at + saved.length <= messages.length; at++)
|
|
723
|
+
if (saved.every((message, i) => isDeepStrictEqual(messages[at + i], message)))
|
|
724
|
+
return [...messages.slice(0, at), ...conversation(withCut(ctx, cut)), ...messages.slice(at + saved.length)];
|
|
725
|
+
throw new Error("The prompt's cut cannot be applied: the request does not carry the saved conversation");
|
|
726
|
+
};
|
|
663
727
|
pi.on("context", (event, ctx) => {
|
|
664
728
|
try {
|
|
665
729
|
if (!active())
|
|
666
730
|
return;
|
|
667
731
|
access();
|
|
668
|
-
return { messages: applyClips(projectedMessages(event.messages, ctx, options.recallPolicy), clips) };
|
|
732
|
+
return { messages: applyClips(projectedMessages(promptCut ? cutRequest(event.messages, ctx, promptCut) : event.messages, ctx, options.recallPolicy), clips) };
|
|
669
733
|
}
|
|
670
734
|
catch (error) {
|
|
671
735
|
try {
|
|
@@ -677,10 +741,6 @@ export async function installCompaction(pi, options = {}) {
|
|
|
677
741
|
}
|
|
678
742
|
});
|
|
679
743
|
pi.on("session_compact_failed", (event, ctx) => {
|
|
680
|
-
// The end of an opportunistic cut that proposed nothing, however it came about, is no event for the recorder or for failure handling;
|
|
681
|
-
// a latched failure still is, and so is the failure of a cut the compactor proposed.
|
|
682
|
-
if (pendingCut?.opportunistic && !failure && !pendingCut.proposed)
|
|
683
|
-
return;
|
|
684
744
|
// 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
|
|
685
745
|
// threshold compaction cancelled and not from an extension: no event for the recorder.
|
|
686
746
|
if (event.reason === "threshold" && event.aborted && !event.fromExtension)
|
|
@@ -707,10 +767,12 @@ export async function installCompaction(pi, options = {}) {
|
|
|
707
767
|
}
|
|
708
768
|
// Pi commits these entries before the run settles.
|
|
709
769
|
try {
|
|
710
|
-
if (!active() || !admittedRun ||
|
|
711
|
-
|
|
770
|
+
if (!active() || !admittedRun || failure || !settingsFor().getCompactionSettings(ctx.model).enabled)
|
|
771
|
+
return;
|
|
772
|
+
const decide = event.outcome === "completed" && !ctx.signal?.aborted;
|
|
773
|
+
if (promptCut ? event.outcome === "error" : !decide)
|
|
712
774
|
return;
|
|
713
|
-
return
|
|
775
|
+
return atBoundary(event, ctx, "run-end", decide);
|
|
714
776
|
}
|
|
715
777
|
catch (error) {
|
|
716
778
|
fail(error, ctx);
|
|
@@ -24,6 +24,7 @@ export declare function inspectIncident(records: IncidentRecord[]): {
|
|
|
24
24
|
mode?: never;
|
|
25
25
|
unavailable: string;
|
|
26
26
|
capacityRejected?: boolean;
|
|
27
|
+
floorMessages?: import("@earendil-works/pi-agent-core").AgentMessage[];
|
|
27
28
|
};
|
|
28
29
|
receiptId: string | null;
|
|
29
30
|
matchesRecordedProposal: boolean | null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type RecallPolicy } from './recall.js';
|
|
2
2
|
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
3
|
-
export declare const DIAGNOSTIC_BUILD = "
|
|
3
|
+
export declare const DIAGNOSTIC_BUILD = "828ac4821689607af006d54a41fd5960bb910bc65aa43adbd81c2f6aac25d180";
|
|
4
4
|
export interface Receipt {
|
|
5
5
|
id: string;
|
|
6
6
|
build: string;
|
package/dist/src/diagnostics.js
CHANGED
|
@@ -8,7 +8,7 @@ import { createRecall } from './recall.js';
|
|
|
8
8
|
export const DIAGNOSTIC_BUILD = BUILD_ID;
|
|
9
9
|
export function attachReceipt(draft, reason, reserveTokens, contextWindow, resolved = {}) {
|
|
10
10
|
const details = draft.details && typeof draft.details === 'object' ? draft.details : {};
|
|
11
|
-
const 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' } };
|
|
11
|
+
const 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' } };
|
|
12
12
|
return { ...draft, details: { ...details, diagnosticReceipt: receipt } };
|
|
13
13
|
}
|
|
14
14
|
export function receiptId(entry) {
|
package/dist/src/tuck.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ type Draft = {
|
|
|
21
21
|
mode?: never;
|
|
22
22
|
unavailable: string;
|
|
23
23
|
capacityRejected?: boolean;
|
|
24
|
+
floorMessages?: AgentMessage[];
|
|
24
25
|
};
|
|
25
26
|
interface SearchHit {
|
|
26
27
|
index: number;
|
|
@@ -84,13 +85,15 @@ export declare function historicalAllowance(window: number, reserve: number, bas
|
|
|
84
85
|
export declare function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean, operatorManual?: boolean, newPrompt?: boolean): Draft;
|
|
85
86
|
/** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
|
|
86
87
|
/** `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
|
|
87
|
-
* a run a custom message starts); the session has moved on from an ended run.
|
|
88
|
+
* a run a custom message starts); the session has moved on from an ended run.
|
|
89
|
+
* `reRenderSummary`: the cut is due, so its summary may be re-rendered smaller (see the `no_live_messages` branch below). */
|
|
88
90
|
export declare function tuckObservedDraft(tuck: Tuck, input: {
|
|
89
91
|
branchEntries: SessionEntry[];
|
|
90
92
|
tokensBefore: number;
|
|
91
93
|
previousSummary?: string;
|
|
92
94
|
operatorManual?: boolean;
|
|
93
95
|
newPrompt?: boolean;
|
|
96
|
+
reRenderSummary?: boolean;
|
|
94
97
|
}, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean): Draft;
|
|
95
98
|
/** Numeric references use the original session index, then intersect with readable current-branch entries. */
|
|
96
99
|
export declare function tuckRecall(tuck: Tuck, entries: readonly SessionEntry[], branch: readonly SessionEntry[], input: RecallOptions & {
|
package/dist/src/tuck.js
CHANGED
|
@@ -31,7 +31,8 @@ export function tuckDraft(tuck, event, ctx, reserveTokens, measure, policy = {},
|
|
|
31
31
|
}
|
|
32
32
|
/** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
|
|
33
33
|
/** `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
|
|
34
|
-
* a run a custom message starts); the session has moved on from an ended run.
|
|
34
|
+
* a run a custom message starts); the session has moved on from an ended run.
|
|
35
|
+
* `reRenderSummary`: the cut is due, so its summary may be re-rendered smaller (see the `no_live_messages` branch below). */
|
|
35
36
|
export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, policy = {}, retainCurrentInput = true) {
|
|
36
37
|
const { isHistorySource, readable } = createRecall(policy);
|
|
37
38
|
const projected = ctx.sessionManager.buildSessionProjection();
|
|
@@ -62,6 +63,12 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
62
63
|
if (cut.ok)
|
|
63
64
|
cut = tuck.applyTailBudget(entries, cut, { charsPerToken, maxTokens,
|
|
64
65
|
oversizedFactor: Math.min(OVERSIZED_TAIL_FACTOR, inputRoom / Math.max(1, maxTokens)) });
|
|
66
|
+
// 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
|
|
67
|
+
// 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
|
|
68
|
+
// made smaller. Inside the band a cut is opportunistic, and re-rendering there shrinks the summary and costs history for nothing (replays
|
|
69
|
+
// 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.
|
|
70
|
+
else if (input.reRenderSummary && cut.reason === "no_live_messages" && entries.some(entry => entry.type === "compaction"))
|
|
71
|
+
cut = { ok: true, messages: [], selectedIds: [], firstKeptEntryId: "", compactAll: true, keptUserTurns: 0, keepFallbackToCompactAll: false };
|
|
65
72
|
if (!cut.ok)
|
|
66
73
|
return { unavailable: `pi-tuck cannot select a cut: ${cut.reason}` };
|
|
67
74
|
// The latest call's trailing receipts have not reached a subsequent response.
|
|
@@ -196,7 +203,8 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
196
203
|
const model = ctx.model;
|
|
197
204
|
const floorAt = (keep) => {
|
|
198
205
|
const ledger = [...actionRecords(actions, keep), ...ledgerRecords(items, keep)];
|
|
199
|
-
|
|
206
|
+
const messages = preview(serializeTranscript([...mandatory, ...ledger]) + DIALOGUE_SUFFIX);
|
|
207
|
+
return { ledger, messages, base: measure(messages) };
|
|
200
208
|
};
|
|
201
209
|
const fits = (base) => Boolean(model) && base + reserveTokens < model.contextWindow && base < before;
|
|
202
210
|
// When the whole floor cannot fit, the trail and the ledger keep only their headings and counts; only that can refuse the cut.
|
|
@@ -205,7 +213,7 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
205
213
|
floor = floorAt(0);
|
|
206
214
|
const { ledger, base } = floor;
|
|
207
215
|
if (!model || !fits(base))
|
|
208
|
-
return { unavailable: "pi-tuck's mandatory context cannot fit or reduce the request budget", capacityRejected: Boolean(model && base + reserveTokens >= model.contextWindow) };
|
|
216
|
+
return { unavailable: "pi-tuck's mandatory context cannot fit or reduce the request budget", capacityRejected: Boolean(model && base + reserveTokens >= model.contextWindow), floorMessages: floor.messages };
|
|
209
217
|
const maxBriefChars = historicalAllowance(model.contextWindow, reserveTokens, base, before);
|
|
210
218
|
const compileInput = { messages, sourceIndices, sourceEntryIds: originalCut.selectedIds, sourceTimes: originalCut.selectedIds.map(id => resolveSourceTime(entryById.get(id))), receiptContext,
|
|
211
219
|
ranking: { maxBriefChars, mandatorySourceIndices: new Set(mandatory.flatMap(r => r.sourceIndex === undefined ? [] : [r.sourceIndex])) }, };
|
|
@@ -244,7 +252,7 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
244
252
|
const { summary, after } = offer ?? assemble(tuck.compileShrunkDialogue(compileInput));
|
|
245
253
|
// Draft reduction concerns conversation. Final request admission owns the effective system/tools.
|
|
246
254
|
if (after + reserveTokens >= model.contextWindow || after >= before)
|
|
247
|
-
return { unavailable: "pi-tuck's draft does not reduce context within the request budget", capacityRejected: after + reserveTokens >= model.contextWindow };
|
|
255
|
+
return { unavailable: "pi-tuck's draft does not reduce context within the request budget", capacityRejected: after + reserveTokens >= model.contextWindow, floorMessages: floor.messages };
|
|
248
256
|
const mode = offer ? "dialogue" : "shrunk";
|
|
249
257
|
return { compaction: { summary, firstKeptEntryId: cut.firstKeptEntryId, tokensBefore: input.tokensBefore, details: { ...details, tokensAfter: after, view: offer ? "complete-dialogue" : "shrunk-dialogue", mode } }, after, mode };
|
|
250
258
|
};
|
package/package.json
CHANGED
package/src/build-id.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "828ac4821689607af006d54a41fd5960bb910bc65aa43adbd81c2f6aac25d180";
|
package/src/clip.ts
CHANGED
|
@@ -16,6 +16,19 @@ const clipped = (text: string, keep: number, entryId: string): ToolResultMessage
|
|
|
16
16
|
`tuck_recall {"entryId":"${entryId}","offset":${head}}, up to ${MAX_RECALL_CHARACTERS} characters a call]\n` + text.slice(text.length - tail) }];
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
/** The budgets to offer a fresh round the room cannot hold whole, in the units the round and the mandatory input are counted in, tried in order.
|
|
20
|
+
* The room is the provider's, so it is divided by the factor the provider's counts have been running at - the same conversion the planner's own
|
|
21
|
+
* reserve makes (budget.ts) - before the input no cut can remove is taken out; counting the provider's room against an estimate asks the round for
|
|
22
|
+
* more than the room holds at a factor above 1. The first try is half of what that leaves. `kept` is what the smallest cut leaves the request
|
|
23
|
+
* besides the fresh results - the summary floor and its quote of the prompt - measured from the planner's own preview, not a new formula: a half
|
|
24
|
+
* cannot spend it, so the second try is the whole free room less it. Omit it when the planner bailed before building a preview, and only the half
|
|
25
|
+
* is tried. Nothing is offered when the mandatory input alone leaves no room. */
|
|
26
|
+
export const clipBudgets = (room: number, factor: number, mandatory: number, kept?: number): number[] => {
|
|
27
|
+
const free = Math.floor(room / Math.max(1, factor)) - mandatory;
|
|
28
|
+
if (free <= 0) return [];
|
|
29
|
+
return [...new Set(kept === undefined ? [Math.floor(free / 2)] : [Math.floor(free / 2), free - kept - 1])].filter(budget => budget > 0);
|
|
30
|
+
};
|
|
31
|
+
|
|
19
32
|
/** The round's text results within `budget` tokens together: each result smaller than an equal share of what is left stays whole, and the
|
|
20
33
|
* rest are clipped to that share, keeping the most head and tail characters that fit it. None when the round already fits, or when a share
|
|
21
34
|
* cannot hold even the locator. A round of one result gets the whole budget. Deterministic: the same round and budget always clip the same way. */
|