pi-tuck 1.0.28 → 1.0.29
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 +30 -0
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.d.ts +2 -1
- package/dist/src/compaction.js +93 -26
- package/dist/src/diagnostics.d.ts +2 -1
- package/dist/src/request-capacity.d.ts +9 -1
- package/dist/src/request-capacity.js +27 -3
- package/dist/src/run.d.ts +1 -0
- package/dist/src/run.js +1 -1
- package/package.json +1 -1
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +81 -26
- package/src/diagnostics.ts +1 -1
- package/src/request-capacity.ts +26 -3
- package/src/run.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,36 @@
|
|
|
3
3
|
All notable changes to pi-tuck. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
4
4
|
[Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [1.0.29] - 2026-11-11
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **When Pi's configured window was larger than the server's, a session could get stuck after one overflow.** pi-tuck learned the right
|
|
11
|
+
correction from the server's refusal, then went on refusing every prompt. It mistook a cut Pi had saved for one Pi had failed to save. It
|
|
12
|
+
now checks the session file before treating a save as failed.
|
|
13
|
+
- **A server that silently shrinks the reply to fit its real window went unnoticed** (mac-studio does this when prompt plus reply cap is
|
|
14
|
+
over its window). pi-tuck answered fewer prompts than plain Pi.
|
|
15
|
+
- A reply that stops for length below the reply cap Pi sent was cut off by the server, so its input plus output is the server's real
|
|
16
|
+
window. pi-tuck now learns that window, keeps it for the model in the session, plans with the smaller window, and retries that turn
|
|
17
|
+
once.
|
|
18
|
+
- A reply that stops at the cap Pi sent is still a genuine length stop, under 1.0.25's rules.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **Stress cells for a configured window (81,920) larger than the server's (68,000), each against plain Pi:**
|
|
23
|
+
- a prompt over the window, with mac-studio's exact refusal;
|
|
24
|
+
- a prompt plus reply cap over the window;
|
|
25
|
+
- the reply cut off at exactly the window.
|
|
26
|
+
|
|
27
|
+
pi-tuck now answers at least as many prompts as plain Pi in all three, loses no task and strands no session. Stress counts a reply as an
|
|
28
|
+
answer only when its request carries the task.
|
|
29
|
+
|
|
30
|
+
### Release gates
|
|
31
|
+
|
|
32
|
+
- **Shipped without a live gate, because its replay matches 1.0.28:** 13,946 requests, 0 different beyond noise, 0 dead tasks, clips
|
|
33
|
+
17 → 17.
|
|
34
|
+
- **Strict stress, 313 cells:** 0 findings. Tests (810) and CI. The packed tarball loaded in the real installed Pi.
|
|
35
|
+
|
|
6
36
|
## [1.0.28] - 2026-11-10
|
|
7
37
|
|
|
8
38
|
### Changed
|
package/dist/src/build-id.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const BUILD_ID = "
|
|
1
|
+
export declare const BUILD_ID = "7ff651306c4a9f937b9d1abdc44123ce28cc9a6892f0730b3bef7d8174574a94";
|
package/dist/src/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "7ff651306c4a9f937b9d1abdc44123ce28cc9a6892f0730b3bef7d8174574a94";
|
package/dist/src/compaction.d.ts
CHANGED
|
@@ -51,7 +51,8 @@ export interface CompactionOptions {
|
|
|
51
51
|
cutAt?: number;
|
|
52
52
|
/** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
|
|
53
53
|
* Standalone cuts against Pi's reserveTokens, then admits up to the window less Pi's safety margin and max(3,584, largest branch output), capped at that reserve.
|
|
54
|
-
* After a length stop with text/tool output or context overflow it holds the full reserve, also raised to the catalogue maximum.
|
|
54
|
+
* After a genuine length stop with text/tool output or context overflow it holds the full reserve, also raised to the catalogue maximum.
|
|
55
|
+
* Server clipping below the sent reply cap instead learns the smaller window and preserves the normal reply floor.
|
|
55
56
|
* Thinking-only exhaustion keeps Pi's reserve and requires the branch's largest observed reply instead.
|
|
56
57
|
* Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
|
|
57
58
|
requestReserve?: (ctx: ExtensionContext) => {
|
package/dist/src/compaction.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { textOf } from "./tuck/core/content.js";
|
|
2
2
|
import { PendingCut, StartingPrompt, createRun } from "./run.js";
|
|
3
|
-
import { requestReplyFloor, replyFit, carriesPrompt, thinkingOnlyLength, piContextSafetyMargin, loadPiRequestMath } from "./request-capacity.js";
|
|
3
|
+
import { requestReplyFloor, replyFit, carriesPrompt, thinkingOnlyLength, piContextSafetyMargin, loadPiRequestMath, seedContextWindow, WINDOW_RECEIPT } from "./request-capacity.js";
|
|
4
4
|
import { BUILD_ID } from "./build-id.js";
|
|
5
5
|
import { createSemanticRecall, budgetSemantic } from "./semantic-recall.js";
|
|
6
6
|
import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry } from "./diagnostics.js";
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
8
9
|
import { exportSource } from "./source-export.js";
|
|
9
10
|
import { isDeepStrictEqual } from "node:util";
|
|
10
11
|
import { assertNativeToolRounds, isDiscardedResponse } from "./protocol.js";
|
|
@@ -144,7 +145,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
144
145
|
}
|
|
145
146
|
});
|
|
146
147
|
const captureDraft = (draft, ctx, reserve, reason, tokensBefore, retainCurrentInput, operatorManual, previousSummary, budget) => recorder.measureInline(() => {
|
|
147
|
-
const attached = attachReceipt(draft, reason, reserve, ctx
|
|
148
|
+
const attached = attachReceipt(draft, reason, reserve, contextWindow(ctx), { responseReserveTokens: budget?.response, hostInputTokens: budget?.host, mandatoryInputTokens: budget?.mandatory, target: budget?.target, triggerMarginTokens: budget?.margin, ...(cutAt === 1 ? {} : { cutAt }), calibrationFactor: factor, calibrationWindow: [...ratios], calibrationIdentity: identity, learnedContextWindow, activeInputCeilingTokens: lastCeiling });
|
|
148
149
|
proposalId = receiptId(attached);
|
|
149
150
|
if (recorder.enabled) {
|
|
150
151
|
try {
|
|
@@ -241,6 +242,9 @@ export async function installCompaction(pi, options = {}) {
|
|
|
241
242
|
// One serial request, one accounting identity; observations never certify new content. A new identity (the first request after a session
|
|
242
243
|
// start, or a model switch) starts with the window its branch's newest receipt recorded for that identity, else that receipt's factor, else the model's measured undercount or 1 (`seedWindow`).
|
|
243
244
|
let identity = "", factor = 1, ratios = [];
|
|
245
|
+
let learnedContextWindow;
|
|
246
|
+
const serverClipped = new WeakSet();
|
|
247
|
+
let windowObservation;
|
|
244
248
|
let lastCeiling = null;
|
|
245
249
|
let pending;
|
|
246
250
|
const identityFor = (ctx) => {
|
|
@@ -249,10 +253,27 @@ export async function installCompaction(pi, options = {}) {
|
|
|
249
253
|
identity = key;
|
|
250
254
|
ratios = seedWindow(ctx.sessionManager.getBranch(), key);
|
|
251
255
|
factor = Math.max(1, ...ratios);
|
|
256
|
+
learnedContextWindow = seedContextWindow(ctx.sessionManager.getBranch(), key);
|
|
252
257
|
pending = undefined;
|
|
253
258
|
}
|
|
254
259
|
return key;
|
|
255
260
|
};
|
|
261
|
+
const contextWindow = (ctx) => {
|
|
262
|
+
identityFor(ctx);
|
|
263
|
+
return Math.min(ctx.model?.contextWindow ?? 0, learnedContextWindow ?? Infinity);
|
|
264
|
+
};
|
|
265
|
+
// This is the cap in Pi's built provider payload, including its context clamp and thinking adjustments.
|
|
266
|
+
// An API with no explicit cap supplies no clipping evidence; never substitute the catalogue maximum.
|
|
267
|
+
pi.on("before_provider_request", event => {
|
|
268
|
+
if (!pending || !event.payload || typeof event.payload !== "object")
|
|
269
|
+
return;
|
|
270
|
+
const payload = event.payload;
|
|
271
|
+
// Read at reply time so later in-place payload edits are included too.
|
|
272
|
+
pending.requestedMaxTokens = () => {
|
|
273
|
+
const cap = payload.max_tokens ?? payload.max_completion_tokens ?? payload.max_output_tokens;
|
|
274
|
+
return typeof cap === "number" && Number.isSafeInteger(cap) && cap > 0 ? cap : undefined;
|
|
275
|
+
};
|
|
276
|
+
});
|
|
256
277
|
// The factor is max(1, the last CALIBRATION_WINDOW paired ratios, overflow bounds and seed): it rises at once and a stale high ratio ages out.
|
|
257
278
|
const learn = (value) => { ratios.push(value); if (ratios.length > CALIBRATION_WINDOW)
|
|
258
279
|
ratios.shift(); factor = Math.max(1, ...ratios); };
|
|
@@ -264,6 +285,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
264
285
|
// The cut planner's raw reserve for the target limit, on the same factor as every other consumer.
|
|
265
286
|
const planReserve = (target, room, band, mandatory, ctx) => {
|
|
266
287
|
identityFor(ctx);
|
|
288
|
+
// The compiler subtracts this reserve from the catalogue window. Targets already use the learned window.
|
|
267
289
|
return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ? band : room, factor, mandatory);
|
|
268
290
|
};
|
|
269
291
|
pi.on("message_end", (event, ctx) => {
|
|
@@ -292,9 +314,9 @@ export async function installCompaction(pi, options = {}) {
|
|
|
292
314
|
// 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
|
|
293
315
|
// with no word from pi-tuck read as a hang: every other refusal says why, and this is the path that skipped it.
|
|
294
316
|
if (overflow) {
|
|
295
|
-
const
|
|
317
|
+
const window = contextWindow(ctx), bound = (window + 1) / paired.tokens;
|
|
296
318
|
const learned = bound <= CALIBRATION_CEILING;
|
|
297
|
-
ctx.ui.notify(overflowNotice(
|
|
319
|
+
ctx.ui.notify(overflowNotice(window, bound, learned), "warning");
|
|
298
320
|
if (learned)
|
|
299
321
|
learn(bound);
|
|
300
322
|
return;
|
|
@@ -306,12 +328,28 @@ export async function installCompaction(pi, options = {}) {
|
|
|
306
328
|
if (!counts.every(n => Number.isSafeInteger(n) && n >= 0))
|
|
307
329
|
return;
|
|
308
330
|
const input = counts.reduce((a, b) => a + b, 0), ratio = input / paired.tokens;
|
|
331
|
+
const output = usage.output, bound = usage.input + usage.cacheRead + output, requested = paired.requestedMaxTokens?.();
|
|
332
|
+
if (message.stopReason === "length" && requested !== undefined &&
|
|
333
|
+
Number.isSafeInteger(output) && output >= 0 && output < requested &&
|
|
334
|
+
Number.isSafeInteger(bound) && bound > 0 && bound <= ctx.model.contextWindow) {
|
|
335
|
+
learnedContextWindow = Math.min(learnedContextWindow ?? Infinity, bound);
|
|
336
|
+
serverClipped.add(message);
|
|
337
|
+
windowObservation = { message, identity: key, window: learnedContextWindow };
|
|
338
|
+
}
|
|
309
339
|
// A request that was answered cannot have held more than the model's window, so a report above it describes something that never happened.
|
|
310
340
|
// 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
|
|
311
341
|
// 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.
|
|
312
342
|
if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio))
|
|
313
343
|
learn(ratio);
|
|
314
344
|
});
|
|
345
|
+
pi.on("turn_end", event => {
|
|
346
|
+
if (windowObservation?.message !== event.message)
|
|
347
|
+
return;
|
|
348
|
+
const observation = windowObservation;
|
|
349
|
+
windowObservation = undefined;
|
|
350
|
+
pi.appendEntry(WINDOW_RECEIPT, { calibrationIdentity: observation.identity, learnedContextWindow: observation.window,
|
|
351
|
+
serverClippedReplyId: event.messageEntryId });
|
|
352
|
+
});
|
|
315
353
|
// The operator's length-stop notice is decided at settle and is not attempt state: a run reset before then (a tree move inside another
|
|
316
354
|
// extension's agent_settled, a failure) must not erase it. Only the final assistant reply decides it; a successful retry clears it.
|
|
317
355
|
let lengthStop = false;
|
|
@@ -357,13 +395,13 @@ export async function installCompaction(pi, options = {}) {
|
|
|
357
395
|
lastCeiling = ceiling ?? null;
|
|
358
396
|
if (ceiling !== undefined && (!Number.isSafeInteger(ceiling) || ceiling <= 0))
|
|
359
397
|
throw new Error("Invalid active-input token ceiling");
|
|
360
|
-
return Math.min((ctx
|
|
398
|
+
return Math.min(contextWindow(ctx) - response, ceiling ?? Infinity);
|
|
361
399
|
};
|
|
362
400
|
// Room still owns every existing cut/clip attempt. Only final capacity admission uses the hard line.
|
|
363
401
|
const limits = (ctx) => {
|
|
364
402
|
const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
|
|
365
403
|
const floor = options.requestReserve ? response : requestReplyFloor(ctx.sessionManager.getBranch(), response, ctx.model?.maxTokens);
|
|
366
|
-
const hardLine = Math.min(Math.max(room, (ctx
|
|
404
|
+
const hardLine = Math.min(Math.max(room, contextWindow(ctx) - piMargin - floor), lastCeiling ?? Infinity);
|
|
367
405
|
return { response, room, hardLine, replyFloor: floor };
|
|
368
406
|
};
|
|
369
407
|
const decisionBand = (ctx, room, system, current = view(ctx)) => {
|
|
@@ -396,7 +434,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
396
434
|
// whereas our hard line is in corrected units; fitting that line alone need not leave the branch's reply floor.
|
|
397
435
|
const requestClamp = (messages, ctx) => {
|
|
398
436
|
const context = normalizeContext({ messages: convertToLlm(messages) });
|
|
399
|
-
const cap = piClamp?.(ctx.model, context, ctx.model.maxTokens);
|
|
437
|
+
const cap = piClamp?.({ ...ctx.model, contextWindow: contextWindow(ctx) }, context, ctx.model.maxTokens);
|
|
400
438
|
return { cap, leavesFloor: cap === undefined || cap >= limits(ctx).replyFloor,
|
|
401
439
|
estimate: piMath ? piMath.estimateContextTokens(context).tokens : 0 };
|
|
402
440
|
};
|
|
@@ -432,7 +470,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
432
470
|
return lastAttempt();
|
|
433
471
|
// Shrink in the planner's units by Pi's measured shortfall, at most three times. Once we reach the original room,
|
|
434
472
|
// use its existing band/room policy; admission below the room is deliberately unchanged.
|
|
435
|
-
target = Math.max(room, Math.floor(target * (ctx
|
|
473
|
+
target = Math.max(room, Math.floor(target * (contextWindow(ctx) - piMargin - limits(ctx).replyFloor) / check.estimate));
|
|
436
474
|
decision = replan(target);
|
|
437
475
|
if (decision.kind === "defer" || decision.kind === "fault")
|
|
438
476
|
return lastAttempt();
|
|
@@ -663,14 +701,14 @@ export async function installCompaction(pi, options = {}) {
|
|
|
663
701
|
}
|
|
664
702
|
});
|
|
665
703
|
pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
|
|
666
|
-
pi.on("session_start", () => { resetRun(); recorder.stop("session-transition"); proposalId = undefined; commitFault = undefined; estimatedTokens = 0; identity = ""; factor = 1; });
|
|
704
|
+
pi.on("session_start", () => { resetRun(); recorder.stop("session-transition"); proposalId = undefined; commitFault = undefined; estimatedTokens = 0; identity = ""; factor = 1; learnedContextWindow = undefined; windowObservation = undefined; });
|
|
667
705
|
const historyToolsEnabled = (ctx) => view(ctx).hasTuckCut;
|
|
668
706
|
const syncHistoryTools = (ctx) => { if (active())
|
|
669
707
|
applyHistoryTools(pi, historyToolsEnabled(ctx)); };
|
|
670
708
|
pi.on("session_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
671
709
|
// A plan decided on `input` is for the branch it was decided on: moving in the tree leaves it planned for another branch, whose kept entry
|
|
672
710
|
// may not be on this one.
|
|
673
|
-
pi.on("session_tree", (_event, ctx) => { resetRun(); syncHistoryTools(ctx); });
|
|
711
|
+
pi.on("session_tree", (_event, ctx) => { resetRun(); windowObservation = undefined; learnedContextWindow = seedContextWindow(ctx.sessionManager.getBranch(), identityFor(ctx)); syncHistoryTools(ctx); });
|
|
674
712
|
pi.on("session_compact", (_event, ctx) => { if (run?.phase !== "admitted")
|
|
675
713
|
syncHistoryTools(ctx); });
|
|
676
714
|
pi.on("turn_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
@@ -871,7 +909,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
871
909
|
if (forecast !== saved) {
|
|
872
910
|
const head = getCurrentSystemMessage(convertToLlm(forecast));
|
|
873
911
|
const native = (head ? estimateTokens(head) : 0) + saved.reduce((sum, message) => sum + (message.role === "system" ? 0 : estimateTokens(message)), 0);
|
|
874
|
-
if (shouldCompact(native, ctx
|
|
912
|
+
if (shouldCompact(native, contextWindow(ctx), settingsFor().getCompactionSettings(ctx.model)))
|
|
875
913
|
densityTokens = native;
|
|
876
914
|
}
|
|
877
915
|
}
|
|
@@ -975,6 +1013,20 @@ export async function installCompaction(pi, options = {}) {
|
|
|
975
1013
|
return { entries: [{ type: "compaction", summary: compaction.summary,
|
|
976
1014
|
firstKeptEntryId: compaction.firstKeptEntryId, details: compaction.details }] };
|
|
977
1015
|
};
|
|
1016
|
+
const markBoundary = (entries) => {
|
|
1017
|
+
if (!run)
|
|
1018
|
+
return;
|
|
1019
|
+
run.pendingBoundaryReceipts = entries.flatMap(entry => entry.type === "compaction" ? receiptId(entry) ?? [] : []);
|
|
1020
|
+
run.pendingBoundaryCommit = run.pendingBoundaryReceipts.length > 0;
|
|
1021
|
+
};
|
|
1022
|
+
const boundarySaved = (ctx) => {
|
|
1023
|
+
const wanted = run?.pendingBoundaryReceipts ?? [];
|
|
1024
|
+
const file = ctx.sessionManager.getSessionFile();
|
|
1025
|
+
// appendCompaction mutates memory before writing. On an error boundary, only the persisted entries prove a file-backed cut saved.
|
|
1026
|
+
const entries = file ? readFileSync(file, "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line)) : ctx.sessionManager.getBranch();
|
|
1027
|
+
const saved = new Set(entries.flatMap(entry => entry.type === "compaction" ? receiptId(entry) ?? [] : []));
|
|
1028
|
+
return wanted.every(id => saved.has(id));
|
|
1029
|
+
};
|
|
978
1030
|
const atBoundary = (event, ctx, reason, decide) => {
|
|
979
1031
|
const current = view(ctx);
|
|
980
1032
|
if (!current.pending)
|
|
@@ -989,7 +1041,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
989
1041
|
setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
|
|
990
1042
|
return { entries: [...event.entries, saved, ...(more?.entries ?? [])] };
|
|
991
1043
|
};
|
|
992
|
-
const
|
|
1044
|
+
const retryLength = (event, ctx) => {
|
|
993
1045
|
// Pi's supported boundary drafts commit before finishTurn honors `continue`. Omitting the selected failed response also prevents
|
|
994
1046
|
// Pi's later native length recovery from retrying it independently. The stored message and its output usage stay on the branch.
|
|
995
1047
|
const entries = [...(atBoundary(event, ctx, "run-end", false)?.entries ?? event.entries),
|
|
@@ -1005,7 +1057,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
1005
1057
|
const asIsCheck = requestClamp(asIs, view);
|
|
1006
1058
|
const previousClips = new Map(clips());
|
|
1007
1059
|
const decision = deepestDecision(view, false, true);
|
|
1008
|
-
if (decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
|
|
1060
|
+
if (!serverClipped.has(event.message) && decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
|
|
1009
1061
|
requestClamp(projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true), view).cap <= asIsCheck.cap) {
|
|
1010
1062
|
clips().clear();
|
|
1011
1063
|
for (const [id, clip] of previousClips)
|
|
@@ -1024,22 +1076,26 @@ export async function installCompaction(pi, options = {}) {
|
|
|
1024
1076
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
|
|
1025
1077
|
const tokens = system + conversationTokens(messages), check = requestClamp(messages, view);
|
|
1026
1078
|
const { response, room, hardLine } = limits(view);
|
|
1027
|
-
|
|
1079
|
+
// A learned server boundary uses the same admission rule as the guard: below the room the whole reserve fits in provider units.
|
|
1080
|
+
// Keep the thinking-only rule's unconditional native-clamp floor unchanged.
|
|
1081
|
+
const needsClampFloor = !serverClipped.has(event.message) || corrected(tokens, view) > room;
|
|
1082
|
+
if (!fits(tokens, hardLine, view) || check.cap === undefined || needsClampFloor && !check.leavesFloor)
|
|
1028
1083
|
throw new CapacityError(clampMessage);
|
|
1029
1084
|
const compaction = captureDraft(decision.plan.compaction, view, decision.plan.reserve, "length-retry", tokens, true, false, undefined, { response, host, mandatory: system + host + conversationTokens(event.context.pendingMessages), target: decision.target, margin: room - decisionBand(view, room, system) });
|
|
1030
1085
|
run.thinkingRetryPending = true;
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1086
|
+
const drafts = [...entries, { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId || null, details: compaction.details },
|
|
1087
|
+
{ type: "custom", customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
|
|
1088
|
+
stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: true,
|
|
1089
|
+
estimatedTokens: corrected(tokens, view), piEstimateTokens: check.estimate, piCap: check.cap, room, hardLine, buildId: BUILD_ID } }];
|
|
1090
|
+
markBoundary(drafts);
|
|
1091
|
+
return { entries: drafts, continue: true };
|
|
1036
1092
|
};
|
|
1037
1093
|
pi.on("turn_end", (event, ctx) => {
|
|
1038
1094
|
try {
|
|
1039
1095
|
if (!active() || run?.phase !== "admitted")
|
|
1040
1096
|
return;
|
|
1041
|
-
if (thinkingOnlyLength(event.message) && !failure() && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
|
|
1042
|
-
return
|
|
1097
|
+
if ((thinkingOnlyLength(event.message) || serverClipped.has(event.message)) && !failure() && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
|
|
1098
|
+
return retryLength(event, ctx);
|
|
1043
1099
|
run.thinkingRetryPending = false;
|
|
1044
1100
|
const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
|
|
1045
1101
|
settingsFor().getCompactionSettings(ctx.model).enabled;
|
|
@@ -1047,7 +1103,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
1047
1103
|
return;
|
|
1048
1104
|
const result = atBoundary(event, ctx, "tool-loop", decide);
|
|
1049
1105
|
if (result) {
|
|
1050
|
-
|
|
1106
|
+
markBoundary(result.entries);
|
|
1051
1107
|
}
|
|
1052
1108
|
return result;
|
|
1053
1109
|
}
|
|
@@ -1117,10 +1173,19 @@ export async function installCompaction(pi, options = {}) {
|
|
|
1117
1173
|
});
|
|
1118
1174
|
pi.on("agent_before_settle", (event, ctx) => {
|
|
1119
1175
|
if (run?.pendingBoundaryCommit) {
|
|
1120
|
-
if (event.outcome === "error")
|
|
1121
|
-
|
|
1122
|
-
|
|
1176
|
+
if (event.outcome === "error") {
|
|
1177
|
+
try {
|
|
1178
|
+
if (!boundarySaved(ctx))
|
|
1179
|
+
throw new Error("Boundary receipt absent from the session file");
|
|
1180
|
+
}
|
|
1181
|
+
catch {
|
|
1182
|
+
return void fail(new CommitFault("Native tool-loop boundary cut was not saved in the session file"), ctx);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
if (run) {
|
|
1123
1186
|
run.pendingBoundaryCommit = false;
|
|
1187
|
+
run.pendingBoundaryReceipts = [];
|
|
1188
|
+
}
|
|
1124
1189
|
}
|
|
1125
1190
|
// Pi commits these entries before the run settles.
|
|
1126
1191
|
try {
|
|
@@ -1137,8 +1202,10 @@ export async function installCompaction(pi, options = {}) {
|
|
|
1137
1202
|
});
|
|
1138
1203
|
pi.on("context_with_system", (event, ctx) => {
|
|
1139
1204
|
// Reaching the next request boundary proves native commit returned, not durable storage.
|
|
1140
|
-
if (run)
|
|
1205
|
+
if (run) {
|
|
1141
1206
|
run.pendingBoundaryCommit = false;
|
|
1207
|
+
run.pendingBoundaryReceipts = [];
|
|
1208
|
+
}
|
|
1142
1209
|
pending = undefined;
|
|
1143
1210
|
try {
|
|
1144
1211
|
if (!active())
|
|
@@ -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 = "7ff651306c4a9f937b9d1abdc44123ce28cc9a6892f0730b3bef7d8174574a94";
|
|
4
4
|
export interface Receipt {
|
|
5
5
|
id: string;
|
|
6
6
|
build: string;
|
|
@@ -19,6 +19,7 @@ export interface Receipt {
|
|
|
19
19
|
calibrationFactor?: number;
|
|
20
20
|
calibrationWindow?: number[];
|
|
21
21
|
calibrationIdentity?: string;
|
|
22
|
+
learnedContextWindow?: number;
|
|
22
23
|
activeInputCeilingTokens?: number | null;
|
|
23
24
|
};
|
|
24
25
|
before: {
|
|
@@ -22,7 +22,15 @@ export declare const REPLY_FLOOR_MIN = 3584;
|
|
|
22
22
|
export declare const thinkingOnlyLength: (message: AgentMessage) => boolean;
|
|
23
23
|
/** Thinking-only exhaustion uses the observed largest reply, not the full reserve. Every other failed length/overflow keeps its override. */
|
|
24
24
|
export declare const needsFullReplyReserve: (message: AgentMessage) => boolean;
|
|
25
|
-
/**
|
|
25
|
+
/** Saved immediately at the reply's turn boundary, even when recovery cannot make a compaction. */
|
|
26
|
+
export interface WindowReceipt {
|
|
27
|
+
calibrationIdentity: string;
|
|
28
|
+
learnedContextWindow: number;
|
|
29
|
+
serverClippedReplyId: string;
|
|
30
|
+
}
|
|
31
|
+
export declare const WINDOW_RECEIPT = "pi-tuck-window";
|
|
32
|
+
export declare function seedContextWindow(branch: readonly SessionEntry[], identity: string): number | undefined;
|
|
33
|
+
/** One branch reader for the reserve override and reply floor, including history before cuts. A server-clipping receipt follows its reply. */
|
|
26
34
|
export declare function replyFit(branch: readonly SessionEntry[]): {
|
|
27
35
|
failed: boolean;
|
|
28
36
|
largestOutput: number;
|
|
@@ -64,14 +64,38 @@ export const thinkingOnlyLength = (message) => message.role === 'assistant' && m
|
|
|
64
64
|
/** Thinking-only exhaustion uses the observed largest reply, not the full reserve. Every other failed length/overflow keeps its override. */
|
|
65
65
|
export const needsFullReplyReserve = (message) => message.role === 'assistant' &&
|
|
66
66
|
(message.stopReason === 'length' && !thinkingOnlyLength(message) || message.stopReason === 'error' && isContextOverflow(message));
|
|
67
|
-
|
|
67
|
+
export const WINDOW_RECEIPT = 'pi-tuck-window';
|
|
68
|
+
function windowSettings(entry) {
|
|
69
|
+
if (entry.type === 'custom' && entry.customType === WINDOW_RECEIPT)
|
|
70
|
+
return entry.data;
|
|
71
|
+
if (entry.type === 'compaction')
|
|
72
|
+
return entry.details?.diagnosticReceipt?.settings;
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
export function seedContextWindow(branch, identity) {
|
|
76
|
+
for (let i = branch.length; i--;) {
|
|
77
|
+
const settings = windowSettings(branch[i]);
|
|
78
|
+
if (settings?.calibrationIdentity !== identity)
|
|
79
|
+
continue;
|
|
80
|
+
const window = settings.learnedContextWindow;
|
|
81
|
+
return Number.isSafeInteger(window) && window > 0 ? window : undefined;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
/** One branch reader for the reserve override and reply floor, including history before cuts. A server-clipping receipt follows its reply. */
|
|
68
86
|
export function replyFit(branch) {
|
|
69
87
|
let failed = false, largestOutput = 0, thinkingStop = false;
|
|
70
|
-
|
|
88
|
+
const clipped = new Set();
|
|
89
|
+
for (let i = branch.length; i--;) {
|
|
90
|
+
const entry = branch[i];
|
|
91
|
+
const receipt = entry.type === 'custom' && entry.customType === WINDOW_RECEIPT ? windowSettings(entry) : undefined;
|
|
92
|
+
if (receipt && Number.isSafeInteger(receipt.learnedContextWindow) && receipt.learnedContextWindow > 0 &&
|
|
93
|
+
typeof receipt.serverClippedReplyId === 'string')
|
|
94
|
+
clipped.add(receipt.serverClippedReplyId);
|
|
71
95
|
if (entry.type !== 'message' || entry.message.role !== 'assistant')
|
|
72
96
|
continue;
|
|
73
97
|
const message = entry.message;
|
|
74
|
-
failed ||= needsFullReplyReserve(message);
|
|
98
|
+
failed ||= !clipped.has(entry.id) && needsFullReplyReserve(message);
|
|
75
99
|
thinkingStop ||= thinkingOnlyLength(message);
|
|
76
100
|
const output = message.usage?.output;
|
|
77
101
|
if (typeof output === 'number' && Number.isFinite(output) && output > largestOutput)
|
package/dist/src/run.d.ts
CHANGED
package/dist/src/run.js
CHANGED
|
@@ -86,4 +86,4 @@ export class StartingPrompt {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
export const createRun = (phase) => ({ phase, claimed: false, expired: false,
|
|
89
|
-
cut: new PendingCut(), clips: new Map(), pendingBoundaryCommit: false, thinkingRetryPending: false, refusalReceipted: false });
|
|
89
|
+
cut: new PendingCut(), clips: new Map(), pendingBoundaryCommit: false, pendingBoundaryReceipts: [], thinkingRetryPending: false, refusalReceipted: false });
|
package/package.json
CHANGED
package/src/build-id.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "7ff651306c4a9f937b9d1abdc44123ce28cc9a6892f0730b3bef7d8174574a94";
|
package/src/compaction.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { textOf } from "./tuck/core/content.js";
|
|
2
2
|
import { PendingCut, StartingPrompt, createRun, type CutEntry, type Run } from "./run.js";
|
|
3
|
-
import { requestReplyFloor, replyFit, carriesPrompt, thinkingOnlyLength, piContextSafetyMargin, loadPiRequestMath } from "./request-capacity.js";
|
|
3
|
+
import { requestReplyFloor, replyFit, carriesPrompt, thinkingOnlyLength, piContextSafetyMargin, loadPiRequestMath, seedContextWindow, WINDOW_RECEIPT, type WindowReceipt } from "./request-capacity.js";
|
|
4
4
|
import { BUILD_ID } from "./build-id.js";
|
|
5
5
|
import { createSemanticRecall, budgetSemantic, type SemanticRecallController } from "./semantic-recall";
|
|
6
6
|
import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry, type Receipt, type RecorderOptions } from "./diagnostics.js";
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
8
9
|
import { exportSource } from "./source-export.js";
|
|
9
10
|
import { isDeepStrictEqual } from "node:util";
|
|
10
11
|
import { assertNativeToolRounds, isDiscardedResponse } from "./protocol.js";
|
|
@@ -117,7 +118,8 @@ export interface CompactionOptions {
|
|
|
117
118
|
cutAt?: number;
|
|
118
119
|
/** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
|
|
119
120
|
* Standalone cuts against Pi's reserveTokens, then admits up to the window less Pi's safety margin and max(3,584, largest branch output), capped at that reserve.
|
|
120
|
-
* After a length stop with text/tool output or context overflow it holds the full reserve, also raised to the catalogue maximum.
|
|
121
|
+
* After a genuine length stop with text/tool output or context overflow it holds the full reserve, also raised to the catalogue maximum.
|
|
122
|
+
* Server clipping below the sent reply cap instead learns the smaller window and preserves the normal reply floor.
|
|
121
123
|
* Thinking-only exhaustion keeps Pi's reserve and requires the branch's largest observed reply instead.
|
|
122
124
|
* Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
|
|
123
125
|
requestReserve?: (ctx: ExtensionContext) => { response: number; responseExact?: true };
|
|
@@ -166,7 +168,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
166
168
|
if (found) { recorder.capture("observed-entry", "current-native-branch-memory-not-durable", { entryId: found.id, diagnosticReceiptId: proposalId, details: found.type === "compaction" ? found.details : undefined }); recorder.cut(); proposalId = undefined; }
|
|
167
169
|
});
|
|
168
170
|
const captureDraft = (draft: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>, ctx: ExtensionContext, reserve: number, reason: string, tokensBefore: number, retainCurrentInput: boolean, operatorManual: boolean, previousSummary?: string, budget?: { response: number; host: number; mandatory: number; target: Target; margin: number }) => recorder.measureInline(() => {
|
|
169
|
-
const attached = attachReceipt(draft, reason, reserve, ctx
|
|
171
|
+
const attached = attachReceipt(draft, reason, reserve, contextWindow(ctx), { responseReserveTokens: budget?.response, hostInputTokens: budget?.host, mandatoryInputTokens: budget?.mandatory, target: budget?.target, triggerMarginTokens: budget?.margin, ...(cutAt === 1 ? {} : { cutAt }), calibrationFactor: factor, calibrationWindow: [...ratios], calibrationIdentity: identity, learnedContextWindow, activeInputCeilingTokens: lastCeiling });
|
|
170
172
|
proposalId = receiptId(attached);
|
|
171
173
|
if (recorder.enabled) { try {
|
|
172
174
|
const filtered = permittedEntries(ctx.sessionManager.getEntries(), options.recallPolicy);
|
|
@@ -237,13 +239,31 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
237
239
|
// One serial request, one accounting identity; observations never certify new content. A new identity (the first request after a session
|
|
238
240
|
// start, or a model switch) starts with the window its branch's newest receipt recorded for that identity, else that receipt's factor, else the model's measured undercount or 1 (`seedWindow`).
|
|
239
241
|
let identity = "", factor = 1, ratios: number[] = [];
|
|
242
|
+
let learnedContextWindow: number | undefined;
|
|
243
|
+
const serverClipped = new WeakSet<object>();
|
|
244
|
+
let windowObservation: { message: AgentMessage; identity: string; window: number } | undefined;
|
|
240
245
|
let lastCeiling: number | null = null;
|
|
241
|
-
let pending: { identity: string; tokens: number; messages: Parameters<typeof convertToLlm>[0]; snapshot: ReturnType<typeof convertToLlm
|
|
246
|
+
let pending: { identity: string; tokens: number; messages: Parameters<typeof convertToLlm>[0]; snapshot: ReturnType<typeof convertToLlm>; requestedMaxTokens?: () => number | undefined } | undefined;
|
|
242
247
|
const identityFor = (ctx: ExtensionContext) => {
|
|
243
248
|
const key = ctx.model ? JSON.stringify([ctx.model.provider, ctx.model.api, ctx.model.id]) : "";
|
|
244
|
-
if (key !== identity) { identity = key; ratios = seedWindow(ctx.sessionManager.getBranch(), key); factor = Math.max(1, ...ratios); pending = undefined; }
|
|
249
|
+
if (key !== identity) { identity = key; ratios = seedWindow(ctx.sessionManager.getBranch(), key); factor = Math.max(1, ...ratios); learnedContextWindow = seedContextWindow(ctx.sessionManager.getBranch(), key); pending = undefined; }
|
|
245
250
|
return key;
|
|
246
251
|
};
|
|
252
|
+
const contextWindow = (ctx: ExtensionContext) => {
|
|
253
|
+
identityFor(ctx);
|
|
254
|
+
return Math.min(ctx.model?.contextWindow ?? 0, learnedContextWindow ?? Infinity);
|
|
255
|
+
};
|
|
256
|
+
// This is the cap in Pi's built provider payload, including its context clamp and thinking adjustments.
|
|
257
|
+
// An API with no explicit cap supplies no clipping evidence; never substitute the catalogue maximum.
|
|
258
|
+
pi.on("before_provider_request", event => {
|
|
259
|
+
if (!pending || !event.payload || typeof event.payload !== "object") return;
|
|
260
|
+
const payload = event.payload as Record<string, unknown>;
|
|
261
|
+
// Read at reply time so later in-place payload edits are included too.
|
|
262
|
+
pending.requestedMaxTokens = () => {
|
|
263
|
+
const cap = payload.max_tokens ?? payload.max_completion_tokens ?? payload.max_output_tokens;
|
|
264
|
+
return typeof cap === "number" && Number.isSafeInteger(cap) && cap > 0 ? cap : undefined;
|
|
265
|
+
};
|
|
266
|
+
});
|
|
247
267
|
// The factor is max(1, the last CALIBRATION_WINDOW paired ratios, overflow bounds and seed): it rises at once and a stale high ratio ages out.
|
|
248
268
|
const learn = (value: number) => { ratios.push(value); if (ratios.length > CALIBRATION_WINDOW) ratios.shift(); factor = Math.max(1, ...ratios); };
|
|
249
269
|
const corrected = (tokens: number, ctx: ExtensionContext) => { identityFor(ctx); return Math.ceil(tokens * factor); };
|
|
@@ -253,6 +273,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
253
273
|
// The cut planner's raw reserve for the target limit, on the same factor as every other consumer.
|
|
254
274
|
const planReserve = (target: Target, room: number, band: number, mandatory: number, ctx: ExtensionContext) => {
|
|
255
275
|
identityFor(ctx);
|
|
276
|
+
// The compiler subtracts this reserve from the catalogue window. Targets already use the learned window.
|
|
256
277
|
return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ? band : room, factor, mandatory);
|
|
257
278
|
};
|
|
258
279
|
pi.on("message_end", (event, ctx) => {
|
|
@@ -275,9 +296,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
275
296
|
// 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
|
|
276
297
|
// with no word from pi-tuck read as a hang: every other refusal says why, and this is the path that skipped it.
|
|
277
298
|
if (overflow) {
|
|
278
|
-
const
|
|
299
|
+
const window = contextWindow(ctx), bound = (window + 1) / paired.tokens;
|
|
279
300
|
const learned = bound <= CALIBRATION_CEILING;
|
|
280
|
-
ctx.ui.notify(overflowNotice(
|
|
301
|
+
ctx.ui.notify(overflowNotice(window, bound, learned), "warning");
|
|
281
302
|
if (learned) learn(bound);
|
|
282
303
|
return;
|
|
283
304
|
}
|
|
@@ -286,11 +307,25 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
286
307
|
const counts = [usage.input, usage.cacheRead, usage.cacheWrite];
|
|
287
308
|
if (!counts.every(n => Number.isSafeInteger(n) && n >= 0)) return;
|
|
288
309
|
const input = counts.reduce((a, b) => a + b, 0), ratio = input / paired.tokens;
|
|
310
|
+
const output = usage.output, bound = usage.input + usage.cacheRead + output, requested = paired.requestedMaxTokens?.();
|
|
311
|
+
if (message.stopReason === "length" && requested !== undefined &&
|
|
312
|
+
Number.isSafeInteger(output) && output >= 0 && output < requested &&
|
|
313
|
+
Number.isSafeInteger(bound) && bound > 0 && bound <= ctx.model.contextWindow) {
|
|
314
|
+
learnedContextWindow = Math.min(learnedContextWindow ?? Infinity, bound);
|
|
315
|
+
serverClipped.add(message);
|
|
316
|
+
windowObservation = { message, identity: key, window: learnedContextWindow };
|
|
317
|
+
}
|
|
289
318
|
// A request that was answered cannot have held more than the model's window, so a report above it describes something that never happened.
|
|
290
319
|
// 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
|
|
291
320
|
// 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.
|
|
292
321
|
if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
|
|
293
322
|
});
|
|
323
|
+
pi.on("turn_end", event => {
|
|
324
|
+
if (windowObservation?.message !== event.message) return;
|
|
325
|
+
const observation = windowObservation; windowObservation = undefined;
|
|
326
|
+
pi.appendEntry(WINDOW_RECEIPT, { calibrationIdentity: observation.identity, learnedContextWindow: observation.window,
|
|
327
|
+
serverClippedReplyId: event.messageEntryId } satisfies WindowReceipt);
|
|
328
|
+
});
|
|
294
329
|
// The operator's length-stop notice is decided at settle and is not attempt state: a run reset before then (a tree move inside another
|
|
295
330
|
// extension's agent_settled, a failure) must not erase it. Only the final assistant reply decides it; a successful retry clears it.
|
|
296
331
|
let lengthStop = false;
|
|
@@ -325,13 +360,13 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
325
360
|
lastCeiling = ceiling ?? null;
|
|
326
361
|
if (ceiling !== undefined && (!Number.isSafeInteger(ceiling) || ceiling <= 0))
|
|
327
362
|
throw new Error("Invalid active-input token ceiling");
|
|
328
|
-
return Math.min((ctx
|
|
363
|
+
return Math.min(contextWindow(ctx) - response, ceiling ?? Infinity);
|
|
329
364
|
};
|
|
330
365
|
// Room still owns every existing cut/clip attempt. Only final capacity admission uses the hard line.
|
|
331
366
|
const limits = (ctx: ExtensionContext) => {
|
|
332
367
|
const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
|
|
333
368
|
const floor = options.requestReserve ? response : requestReplyFloor(ctx.sessionManager.getBranch(), response, ctx.model?.maxTokens);
|
|
334
|
-
const hardLine = Math.min(Math.max(room, (ctx
|
|
369
|
+
const hardLine = Math.min(Math.max(room, contextWindow(ctx) - piMargin - floor), lastCeiling ?? Infinity);
|
|
335
370
|
return { response, room, hardLine, replyFloor: floor };
|
|
336
371
|
};
|
|
337
372
|
const decisionBand = (ctx: ExtensionContext, room: number, system: number, current = view(ctx)) => {
|
|
@@ -366,7 +401,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
366
401
|
// whereas our hard line is in corrected units; fitting that line alone need not leave the branch's reply floor.
|
|
367
402
|
const requestClamp = (messages: Messages, ctx: ExtensionContext) => {
|
|
368
403
|
const context = normalizeContext({ messages: convertToLlm(messages) });
|
|
369
|
-
const cap = piClamp?.(ctx.model!, context, ctx.model!.maxTokens);
|
|
404
|
+
const cap = piClamp?.({ ...ctx.model!, contextWindow: contextWindow(ctx) }, context, ctx.model!.maxTokens);
|
|
370
405
|
return { cap, leavesFloor: cap === undefined || cap >= limits(ctx).replyFloor,
|
|
371
406
|
estimate: piMath ? piMath.estimateContextTokens(context).tokens : 0 };
|
|
372
407
|
};
|
|
@@ -396,7 +431,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
396
431
|
if (retry === 3) return lastAttempt();
|
|
397
432
|
// Shrink in the planner's units by Pi's measured shortfall, at most three times. Once we reach the original room,
|
|
398
433
|
// use its existing band/room policy; admission below the room is deliberately unchanged.
|
|
399
|
-
target = Math.max(room, Math.floor(target * (ctx
|
|
434
|
+
target = Math.max(room, Math.floor(target * (contextWindow(ctx) - piMargin - limits(ctx).replyFloor) / check.estimate));
|
|
400
435
|
decision = replan(target);
|
|
401
436
|
if (decision.kind === "defer" || decision.kind === "fault") return lastAttempt();
|
|
402
437
|
}
|
|
@@ -576,13 +611,13 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
576
611
|
}
|
|
577
612
|
});
|
|
578
613
|
pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
|
|
579
|
-
pi.on("session_start", () => { resetRun(); recorder.stop("session-transition"); proposalId = undefined; commitFault = undefined; estimatedTokens = 0; identity = ""; factor = 1; });
|
|
614
|
+
pi.on("session_start", () => { resetRun(); recorder.stop("session-transition"); proposalId = undefined; commitFault = undefined; estimatedTokens = 0; identity = ""; factor = 1; learnedContextWindow = undefined; windowObservation = undefined; });
|
|
580
615
|
const historyToolsEnabled = (ctx: ExtensionContext) => view(ctx).hasTuckCut;
|
|
581
616
|
const syncHistoryTools = (ctx: ExtensionContext) => { if (active()) applyHistoryTools(pi, historyToolsEnabled(ctx)); };
|
|
582
617
|
pi.on("session_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
583
618
|
// A plan decided on `input` is for the branch it was decided on: moving in the tree leaves it planned for another branch, whose kept entry
|
|
584
619
|
// may not be on this one.
|
|
585
|
-
pi.on("session_tree", (_event, ctx) => { resetRun(); syncHistoryTools(ctx); });
|
|
620
|
+
pi.on("session_tree", (_event, ctx) => { resetRun(); windowObservation = undefined; learnedContextWindow = seedContextWindow(ctx.sessionManager.getBranch(), identityFor(ctx)); syncHistoryTools(ctx); });
|
|
586
621
|
pi.on("session_compact", (_event, ctx) => { if (run?.phase !== "admitted") syncHistoryTools(ctx); });
|
|
587
622
|
pi.on("turn_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
588
623
|
pi.on("agent_settled", (_event, ctx) => syncHistoryTools(ctx));
|
|
@@ -761,7 +796,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
761
796
|
if (forecast !== saved) {
|
|
762
797
|
const head = getCurrentSystemMessage(convertToLlm(forecast));
|
|
763
798
|
const native = (head ? estimateTokens(head) : 0) + saved.reduce((sum, message) => sum + (message.role === "system" ? 0 : estimateTokens(message)), 0);
|
|
764
|
-
if (shouldCompact(native, ctx
|
|
799
|
+
if (shouldCompact(native, contextWindow(ctx), settingsFor().getCompactionSettings(ctx.model))) densityTokens = native;
|
|
765
800
|
}
|
|
766
801
|
}
|
|
767
802
|
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
@@ -857,6 +892,19 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
857
892
|
// 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
|
|
858
893
|
// the session as it is with that cut: a fresh result can still need a cut of its own there.
|
|
859
894
|
type Boundary = Parameters<typeof boundaryCut>[0];
|
|
895
|
+
const markBoundary = (entries: readonly SessionBoundaryDraft[]) => {
|
|
896
|
+
if (!run) return;
|
|
897
|
+
run.pendingBoundaryReceipts = entries.flatMap(entry => entry.type === "compaction" ? receiptId(entry) ?? [] : []);
|
|
898
|
+
run.pendingBoundaryCommit = run.pendingBoundaryReceipts.length > 0;
|
|
899
|
+
};
|
|
900
|
+
const boundarySaved = (ctx: ExtensionContext) => {
|
|
901
|
+
const wanted = run?.pendingBoundaryReceipts ?? [];
|
|
902
|
+
const file = ctx.sessionManager.getSessionFile();
|
|
903
|
+
// appendCompaction mutates memory before writing. On an error boundary, only the persisted entries prove a file-backed cut saved.
|
|
904
|
+
const entries: SessionEntry[] = file ? readFileSync(file, "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line)) : ctx.sessionManager.getBranch();
|
|
905
|
+
const saved = new Set(entries.flatMap(entry => entry.type === "compaction" ? receiptId(entry) ?? [] : []));
|
|
906
|
+
return wanted.every(id => saved.has(id));
|
|
907
|
+
};
|
|
860
908
|
const atBoundary = (event: Boundary, ctx: ExtensionContext, reason: "tool-loop" | "run-end", decide: boolean) => {
|
|
861
909
|
const current = view(ctx);
|
|
862
910
|
if (!current.pending) return decide ? boundaryCut(event, ctx, reason) : undefined;
|
|
@@ -870,7 +918,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
870
918
|
setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
|
|
871
919
|
return { entries: [...event.entries as CutEntry[], saved, ...(more?.entries ?? [])] };
|
|
872
920
|
};
|
|
873
|
-
const
|
|
921
|
+
const retryLength = (event: TurnEndEvent, ctx: ExtensionContext) => {
|
|
874
922
|
// Pi's supported boundary drafts commit before finishTurn honors `continue`. Omitting the selected failed response also prevents
|
|
875
923
|
// Pi's later native length recovery from retrying it independently. The stored message and its output usage stay on the branch.
|
|
876
924
|
const entries: SessionBoundaryDraft[] = [...(atBoundary(event, ctx, "run-end", false)?.entries ?? event.entries),
|
|
@@ -885,7 +933,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
885
933
|
const asIsCheck = requestClamp(asIs, view);
|
|
886
934
|
const previousClips = new Map(clips());
|
|
887
935
|
const decision = deepestDecision(view, false, true);
|
|
888
|
-
if (decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
|
|
936
|
+
if (!serverClipped.has(event.message) && decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
|
|
889
937
|
requestClamp(projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true), view).cap! <= asIsCheck.cap) {
|
|
890
938
|
clips().clear(); for (const [id, clip] of previousClips) clips().set(id, clip);
|
|
891
939
|
const { room, hardLine } = limits(view);
|
|
@@ -900,28 +948,32 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
900
948
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
|
|
901
949
|
const tokens = system + conversationTokens(messages), check = requestClamp(messages, view);
|
|
902
950
|
const { response, room, hardLine } = limits(view);
|
|
903
|
-
|
|
951
|
+
// A learned server boundary uses the same admission rule as the guard: below the room the whole reserve fits in provider units.
|
|
952
|
+
// Keep the thinking-only rule's unconditional native-clamp floor unchanged.
|
|
953
|
+
const needsClampFloor = !serverClipped.has(event.message) || corrected(tokens, view) > room;
|
|
954
|
+
if (!fits(tokens, hardLine, view) || check.cap === undefined || needsClampFloor && !check.leavesFloor) throw new CapacityError(clampMessage);
|
|
904
955
|
const compaction = captureDraft(decision.plan.compaction, view, decision.plan.reserve, "length-retry", tokens, true, false, undefined,
|
|
905
956
|
{ response, host, mandatory: system + host + conversationTokens(event.context.pendingMessages), target: decision.target, margin: room - decisionBand(view, room, system) });
|
|
906
957
|
run!.thinkingRetryPending = true;
|
|
907
|
-
|
|
908
|
-
return { entries: [...entries, { type: "compaction" as const, summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId || null, details: compaction.details },
|
|
958
|
+
const drafts = [...entries, { type: "compaction" as const, summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId || null, details: compaction.details },
|
|
909
959
|
{ type: "custom" as const, customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
|
|
910
960
|
stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: true,
|
|
911
|
-
estimatedTokens: corrected(tokens, view), piEstimateTokens: check.estimate, piCap: check.cap, room, hardLine, buildId: BUILD_ID } }]
|
|
961
|
+
estimatedTokens: corrected(tokens, view), piEstimateTokens: check.estimate, piCap: check.cap, room, hardLine, buildId: BUILD_ID } }];
|
|
962
|
+
markBoundary(drafts);
|
|
963
|
+
return { entries: drafts, continue: true };
|
|
912
964
|
};
|
|
913
965
|
pi.on("turn_end", (event, ctx) => {
|
|
914
966
|
try {
|
|
915
967
|
if (!active() || run?.phase !== "admitted") return;
|
|
916
|
-
if (thinkingOnlyLength(event.message) && !failure() && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
|
|
917
|
-
return
|
|
968
|
+
if ((thinkingOnlyLength(event.message) || serverClipped.has(event.message)) && !failure() && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
|
|
969
|
+
return retryLength(event, ctx);
|
|
918
970
|
run.thinkingRetryPending = false;
|
|
919
971
|
const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
|
|
920
972
|
settingsFor().getCompactionSettings(ctx.model).enabled;
|
|
921
973
|
if (!view(ctx).pending && !decide) return;
|
|
922
974
|
const result = atBoundary(event, ctx, "tool-loop", decide);
|
|
923
975
|
if (result) {
|
|
924
|
-
|
|
976
|
+
markBoundary(result.entries);
|
|
925
977
|
}
|
|
926
978
|
return result;
|
|
927
979
|
} catch (error) { fail(error, ctx); }
|
|
@@ -971,8 +1023,11 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
971
1023
|
});
|
|
972
1024
|
pi.on("agent_before_settle", (event, ctx) => {
|
|
973
1025
|
if (run?.pendingBoundaryCommit) {
|
|
974
|
-
if (event.outcome === "error")
|
|
975
|
-
|
|
1026
|
+
if (event.outcome === "error") {
|
|
1027
|
+
try { if (!boundarySaved(ctx)) throw new Error("Boundary receipt absent from the session file"); }
|
|
1028
|
+
catch { return void fail(new CommitFault("Native tool-loop boundary cut was not saved in the session file"), ctx); }
|
|
1029
|
+
}
|
|
1030
|
+
if (run) { run.pendingBoundaryCommit = false; run.pendingBoundaryReceipts = []; }
|
|
976
1031
|
}
|
|
977
1032
|
// Pi commits these entries before the run settles.
|
|
978
1033
|
try {
|
|
@@ -984,7 +1039,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
984
1039
|
});
|
|
985
1040
|
pi.on("context_with_system", (event, ctx) => {
|
|
986
1041
|
// Reaching the next request boundary proves native commit returned, not durable storage.
|
|
987
|
-
if (run) run.pendingBoundaryCommit = false;
|
|
1042
|
+
if (run) { run.pendingBoundaryCommit = false; run.pendingBoundaryReceipts = []; }
|
|
988
1043
|
pending = undefined;
|
|
989
1044
|
try {
|
|
990
1045
|
if (!active()) return;
|
package/src/diagnostics.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { homedir } from 'node:os';
|
|
|
7
7
|
import { createRecall, type RecallPolicy } from './recall.js';
|
|
8
8
|
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
9
9
|
export const DIAGNOSTIC_BUILD = BUILD_ID;
|
|
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' }; }
|
|
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; learnedContextWindow?: number; 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
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' } };
|
package/src/request-capacity.ts
CHANGED
|
@@ -62,13 +62,36 @@ export const thinkingOnlyLength = (message: AgentMessage): boolean => message.ro
|
|
|
62
62
|
export const needsFullReplyReserve = (message: AgentMessage): boolean => message.role === 'assistant' &&
|
|
63
63
|
(message.stopReason === 'length' && !thinkingOnlyLength(message) || message.stopReason === 'error' && isContextOverflow(message));
|
|
64
64
|
|
|
65
|
-
/**
|
|
65
|
+
/** Saved immediately at the reply's turn boundary, even when recovery cannot make a compaction. */
|
|
66
|
+
export interface WindowReceipt { calibrationIdentity: string; learnedContextWindow: number; serverClippedReplyId: string }
|
|
67
|
+
export const WINDOW_RECEIPT = 'pi-tuck-window';
|
|
68
|
+
function windowSettings(entry: SessionEntry): Partial<WindowReceipt> | undefined {
|
|
69
|
+
if (entry.type === 'custom' && entry.customType === WINDOW_RECEIPT) return entry.data as Partial<WindowReceipt> | undefined;
|
|
70
|
+
if (entry.type === 'compaction') return (entry.details as { diagnosticReceipt?: { settings?: Partial<WindowReceipt> } } | undefined)?.diagnosticReceipt?.settings;
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
export function seedContextWindow(branch: readonly SessionEntry[], identity: string): number | undefined {
|
|
74
|
+
for (let i = branch.length; i--;) {
|
|
75
|
+
const settings = windowSettings(branch[i]!);
|
|
76
|
+
if (settings?.calibrationIdentity !== identity) continue;
|
|
77
|
+
const window = settings.learnedContextWindow;
|
|
78
|
+
return Number.isSafeInteger(window) && window! > 0 ? window : undefined;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One branch reader for the reserve override and reply floor, including history before cuts. A server-clipping receipt follows its reply. */
|
|
66
84
|
export function replyFit(branch: readonly SessionEntry[]): { failed: boolean; largestOutput: number; thinkingStop: boolean } {
|
|
67
85
|
let failed = false, largestOutput = 0, thinkingStop = false;
|
|
68
|
-
|
|
86
|
+
const clipped = new Set<string>();
|
|
87
|
+
for (let i = branch.length; i--;) {
|
|
88
|
+
const entry = branch[i]!;
|
|
89
|
+
const receipt = entry.type === 'custom' && entry.customType === WINDOW_RECEIPT ? windowSettings(entry) : undefined;
|
|
90
|
+
if (receipt && Number.isSafeInteger(receipt.learnedContextWindow) && receipt.learnedContextWindow! > 0 &&
|
|
91
|
+
typeof receipt.serverClippedReplyId === 'string') clipped.add(receipt.serverClippedReplyId);
|
|
69
92
|
if (entry.type !== 'message' || entry.message.role !== 'assistant') continue;
|
|
70
93
|
const message = entry.message;
|
|
71
|
-
failed ||= needsFullReplyReserve(message);
|
|
94
|
+
failed ||= !clipped.has(entry.id) && needsFullReplyReserve(message);
|
|
72
95
|
thinkingStop ||= thinkingOnlyLength(message);
|
|
73
96
|
const output = message.usage?.output;
|
|
74
97
|
if (typeof output === 'number' && Number.isFinite(output) && output > largestOutput) largestOutput = output;
|
package/src/run.ts
CHANGED
|
@@ -83,9 +83,10 @@ export interface Run {
|
|
|
83
83
|
cut: PendingCut;
|
|
84
84
|
clips: Map<string, Clip>;
|
|
85
85
|
pendingBoundaryCommit: boolean;
|
|
86
|
+
pendingBoundaryReceipts: string[];
|
|
86
87
|
thinkingRetryPending: boolean;
|
|
87
88
|
failure?: Error;
|
|
88
89
|
refusalReceipted: boolean;
|
|
89
90
|
}
|
|
90
91
|
export const createRun = (phase: Run['phase']): Run => ({ phase, claimed: false, expired: false,
|
|
91
|
-
cut: new PendingCut(), clips: new Map(), pendingBoundaryCommit: false, thinkingRetryPending: false, refusalReceipted: false });
|
|
92
|
+
cut: new PendingCut(), clips: new Map(), pendingBoundaryCommit: false, pendingBoundaryReceipts: [], thinkingRetryPending: false, refusalReceipted: false });
|