pi-tuck 1.0.27 → 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 +57 -0
- package/README.md +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +37 -24
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.d.ts +6 -1
- package/dist/src/compaction.js +241 -194
- package/dist/src/diagnostics.d.ts +2 -1
- package/dist/src/request-capacity.d.ts +14 -1
- package/dist/src/request-capacity.js +40 -11
- package/dist/src/run.d.ts +53 -0
- package/dist/src/run.js +89 -0
- package/dist/src/semantic-recall.d.ts +72 -2
- package/dist/src/semantic-recall.js +58 -12
- package/dist/src/status.d.ts +1 -0
- package/dist/src/status.js +2 -1
- package/dist/src/tuck.js +20 -20
- package/index.ts +31 -22
- package/package.json +1 -1
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +210 -177
- package/src/diagnostics.ts +1 -1
- package/src/request-capacity.ts +38 -9
- package/src/run.ts +92 -0
- package/src/semantic-recall.ts +46 -14
- package/src/status.ts +3 -1
- package/src/tuck.ts +18 -18
package/dist/src/compaction.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { textOf } from "./tuck/core/content.js";
|
|
2
|
-
import {
|
|
2
|
+
import { PendingCut, StartingPrompt, createRun } from "./run.js";
|
|
3
|
+
import { requestReplyFloor, replyFit, carriesPrompt, thinkingOnlyLength, piContextSafetyMargin, loadPiRequestMath, seedContextWindow, WINDOW_RECEIPT } from "./request-capacity.js";
|
|
3
4
|
import { BUILD_ID } from "./build-id.js";
|
|
4
5
|
import { createSemanticRecall, budgetSemantic } from "./semantic-recall.js";
|
|
5
6
|
import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry } from "./diagnostics.js";
|
|
6
7
|
import { createHash } from "node:crypto";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
7
9
|
import { exportSource } from "./source-export.js";
|
|
8
10
|
import { isDeepStrictEqual } from "node:util";
|
|
9
11
|
import { assertNativeToolRounds, isDiscardedResponse } from "./protocol.js";
|
|
@@ -11,7 +13,7 @@ import { projectedMessages } from "./omissions.js";
|
|
|
11
13
|
import { applyClips, clipBudgets, clipFor } from "./clip.js";
|
|
12
14
|
import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt } from "./budget.js";
|
|
13
15
|
import { getCurrentSystemMessage, isContextOverflow, normalizeContext } from "@earendil-works/pi-ai";
|
|
14
|
-
import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact,
|
|
16
|
+
import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
15
17
|
import { Type } from "typebox";
|
|
16
18
|
import { Check } from "typebox/value";
|
|
17
19
|
import { MAX_RECALL_CHARACTERS, MAX_RECALL_ENTRY_ID_CHARACTERS, MAX_RECALL_QUERY_CHARACTERS } from "./recall.js";
|
|
@@ -19,7 +21,7 @@ import { tuck, tuckDraft, tuckObservedDraft, tuckRecall } from "./tuck.js";
|
|
|
19
21
|
import { LOADED_VERSION, statusText } from "./status.js";
|
|
20
22
|
import { ImageRecallInput, historyImage, historyAround } from "./history-image.js";
|
|
21
23
|
import { eligibilityKey, eligibilityKeyMoved, memoizedEligibleSources } from "./eligibility-cache.js";
|
|
22
|
-
import { applyHistoryTools,
|
|
24
|
+
import { applyHistoryTools, historyToolDeclarations, historyToolNames, stageHistoryTools } from "./history-tools.js";
|
|
23
25
|
const query = Type.String({ minLength: 1, maxLength: MAX_RECALL_QUERY_CHARACTERS });
|
|
24
26
|
const entryId = Type.String({ minLength: 1, maxLength: MAX_RECALL_ENTRY_ID_CHARACTERS });
|
|
25
27
|
const paging = {
|
|
@@ -43,7 +45,7 @@ const RecallInput = Type.Union([
|
|
|
43
45
|
const SearchInput = Type.Object({ query, priorResults: Type.Optional(Type.Array(Type.Object({ sourceSha: Type.String({ minLength: 64, maxLength: 64 }), read: Type.Object({ entryId, offset: Type.Integer({ minimum: 0 }), limit: Type.Integer({ minimum: 0 }) }, { additionalProperties: false }) }, { additionalProperties: false }), { maxItems: 20 })) }, { additionalProperties: false });
|
|
44
46
|
const reply = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], details: {} });
|
|
45
47
|
/** The compactor's verdict that this attempt cannot be made: the cut it needs cannot be made, or the request cannot fit. Like every failure but
|
|
46
|
-
* a `CommitFault`, it ends with its attempt (`
|
|
48
|
+
* a `CommitFault`, it ends with its attempt (`resetRun`), and the next attempt plans afresh from the session as it is. */
|
|
47
49
|
class Refusal extends Error {
|
|
48
50
|
}
|
|
49
51
|
/** A cut this compactor supplied that Pi then failed to commit. Pi appends a cut to the session in memory before it writes the file, so the
|
|
@@ -122,11 +124,16 @@ export async function installCompaction(pi, options = {}) {
|
|
|
122
124
|
// Semantic indexing outlives any one attempt, so it checks only that the host still grants access: a compactor failure, which ends with its
|
|
123
125
|
// attempt, must not read as a revocation (that disables semantic recall and deletes its index for the rest of the session).
|
|
124
126
|
// A prompt's cut reaches the requests at once but the session only at the end of the first turn: until then search sees the history as the model does.
|
|
125
|
-
const
|
|
127
|
+
const view = (ctx, threshold = false) => (threshold && run?.expired ? liveCut : run?.cut ?? liveCut).view(ctx);
|
|
128
|
+
const liveCut = new PendingCut();
|
|
129
|
+
const semanticProvider = (ctx) => () => {
|
|
130
|
+
const current = view(ctx);
|
|
131
|
+
return memoizedEligibleSources(ctx, options.recallPolicy, hostAccess, current.pending ? current.keptFromId : undefined);
|
|
132
|
+
};
|
|
126
133
|
const recorder = new IncidentRecorder(options.diagnostics);
|
|
127
134
|
let proposalId;
|
|
128
135
|
const observe = (ctx) => recorder.measureInline(() => {
|
|
129
|
-
if (!active() || failure)
|
|
136
|
+
if (!active() || failure())
|
|
130
137
|
return;
|
|
131
138
|
if (!proposalId)
|
|
132
139
|
return;
|
|
@@ -138,7 +145,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
138
145
|
}
|
|
139
146
|
});
|
|
140
147
|
const captureDraft = (draft, ctx, reserve, reason, tokensBefore, retainCurrentInput, operatorManual, previousSummary, budget) => recorder.measureInline(() => {
|
|
141
|
-
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 });
|
|
142
149
|
proposalId = receiptId(attached);
|
|
143
150
|
if (recorder.enabled) {
|
|
144
151
|
try {
|
|
@@ -218,21 +225,26 @@ export async function installCompaction(pi, options = {}) {
|
|
|
218
225
|
ctx.ui.notify(JSON.stringify({ ...semanticStatus(), ...(errors.length ? { errors } : {}) }), errors.length ? "warning" : "info");
|
|
219
226
|
} });
|
|
220
227
|
pi.registerCommand("tuck", { description: "pi-tuck's state in this session: version, cuts, cut point, search", handler: async (_args, ctx) => {
|
|
221
|
-
const
|
|
222
|
-
ctx.ui.notify(statusText({ version: LOADED_VERSION, active: active(), branch: ctx.sessionManager.getBranch(), cutAt: options.cutAt, search, semantic:
|
|
228
|
+
const state = semantic.status();
|
|
229
|
+
ctx.ui.notify(statusText({ version: LOADED_VERSION, active: active(), branch: ctx.sessionManager.getBranch(), cutAt: options.cutAt, search, semantic: state, pending: view(ctx).pending }), "info");
|
|
223
230
|
} });
|
|
224
231
|
// Pi's native start/settled events bracket the run and its post-turn cuts.
|
|
225
|
-
let
|
|
226
|
-
|
|
227
|
-
let
|
|
228
|
-
|
|
229
|
-
const protectedPrompt = (ctx) =>
|
|
230
|
-
|
|
232
|
+
let run;
|
|
233
|
+
// A native append may have mutated memory before its disk write failed. This session fault outlives the run.
|
|
234
|
+
let commitFault;
|
|
235
|
+
const failure = () => commitFault ?? run?.failure;
|
|
236
|
+
const protectedPrompt = (ctx) => run?.prompt?.id(ctx.sessionManager.getBranch());
|
|
237
|
+
const clips = () => run?.clips ?? emptyClips;
|
|
238
|
+
const emptyClips = new Map();
|
|
239
|
+
const resetRun = () => { run = undefined; pending = undefined; };
|
|
231
240
|
const promptRefusal = () => new Refusal("Prompt check failed: the whole starting prompt cannot fit after cutting to the band and room, shrinking the summary and clipping fresh results. Shorten the prompt or choose a model with a larger context window, then retry");
|
|
232
241
|
let estimatedTokens = 0;
|
|
233
242
|
// One serial request, one accounting identity; observations never certify new content. A new identity (the first request after a session
|
|
234
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`).
|
|
235
244
|
let identity = "", factor = 1, ratios = [];
|
|
245
|
+
let learnedContextWindow;
|
|
246
|
+
const serverClipped = new WeakSet();
|
|
247
|
+
let windowObservation;
|
|
236
248
|
let lastCeiling = null;
|
|
237
249
|
let pending;
|
|
238
250
|
const identityFor = (ctx) => {
|
|
@@ -241,10 +253,27 @@ export async function installCompaction(pi, options = {}) {
|
|
|
241
253
|
identity = key;
|
|
242
254
|
ratios = seedWindow(ctx.sessionManager.getBranch(), key);
|
|
243
255
|
factor = Math.max(1, ...ratios);
|
|
256
|
+
learnedContextWindow = seedContextWindow(ctx.sessionManager.getBranch(), key);
|
|
244
257
|
pending = undefined;
|
|
245
258
|
}
|
|
246
259
|
return key;
|
|
247
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
|
+
});
|
|
248
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.
|
|
249
278
|
const learn = (value) => { ratios.push(value); if (ratios.length > CALIBRATION_WINDOW)
|
|
250
279
|
ratios.shift(); factor = Math.max(1, ...ratios); };
|
|
@@ -256,6 +285,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
256
285
|
// The cut planner's raw reserve for the target limit, on the same factor as every other consumer.
|
|
257
286
|
const planReserve = (target, room, band, mandatory, ctx) => {
|
|
258
287
|
identityFor(ctx);
|
|
288
|
+
// The compiler subtracts this reserve from the catalogue window. Targets already use the learned window.
|
|
259
289
|
return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ? band : room, factor, mandatory);
|
|
260
290
|
};
|
|
261
291
|
pi.on("message_end", (event, ctx) => {
|
|
@@ -274,7 +304,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
274
304
|
pending = undefined;
|
|
275
305
|
const key = identityFor(ctx), message = event.message;
|
|
276
306
|
const overflow = message.stopReason === "error" && isContextOverflow(message);
|
|
277
|
-
if (!paired || !key || paired.identity !== key || !active() || failure || ctx.signal?.aborted || (message.errorMessage && !overflow) ||
|
|
307
|
+
if (!paired || !key || paired.identity !== key || !active() || failure() || ctx.signal?.aborted || (message.errorMessage && !overflow) ||
|
|
278
308
|
message.provider !== ctx.model?.provider || message.model !== ctx.model.id || message.api !== ctx.model.api ||
|
|
279
309
|
(!overflow && !["stop", "toolUse", "length"].includes(message.stopReason)) ||
|
|
280
310
|
!isDeepStrictEqual(convertToLlm(paired.messages), paired.snapshot))
|
|
@@ -284,9 +314,9 @@ export async function installCompaction(pi, options = {}) {
|
|
|
284
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
|
|
285
315
|
// with no word from pi-tuck read as a hang: every other refusal says why, and this is the path that skipped it.
|
|
286
316
|
if (overflow) {
|
|
287
|
-
const
|
|
317
|
+
const window = contextWindow(ctx), bound = (window + 1) / paired.tokens;
|
|
288
318
|
const learned = bound <= CALIBRATION_CEILING;
|
|
289
|
-
ctx.ui.notify(overflowNotice(
|
|
319
|
+
ctx.ui.notify(overflowNotice(window, bound, learned), "warning");
|
|
290
320
|
if (learned)
|
|
291
321
|
learn(bound);
|
|
292
322
|
return;
|
|
@@ -298,14 +328,52 @@ export async function installCompaction(pi, options = {}) {
|
|
|
298
328
|
if (!counts.every(n => Number.isSafeInteger(n) && n >= 0))
|
|
299
329
|
return;
|
|
300
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
|
+
}
|
|
301
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.
|
|
302
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
|
|
303
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.
|
|
304
342
|
if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio))
|
|
305
343
|
learn(ratio);
|
|
306
344
|
});
|
|
307
|
-
pi.on("
|
|
308
|
-
|
|
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
|
+
});
|
|
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
|
|
354
|
+
// extension's agent_settled, a failure) must not erase it. Only the final assistant reply decides it; a successful retry clears it.
|
|
355
|
+
let lengthStop = false;
|
|
356
|
+
pi.on("agent_start", () => { run ??= createRun("admitted"); run.phase = "admitted"; run.thinkingRetryPending = false; lengthStop = false; pending = undefined; });
|
|
357
|
+
pi.on("message_end", event => { if (event.message.role === "assistant")
|
|
358
|
+
lengthStop = event.message.stopReason === "length"; });
|
|
359
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
360
|
+
try {
|
|
361
|
+
if (!lengthStop || !active())
|
|
362
|
+
return;
|
|
363
|
+
lengthStop = false;
|
|
364
|
+
const message = "pi-tuck: the last reply stopped at the model's output limit and may be cut off. Ask the agent to continue.";
|
|
365
|
+
try {
|
|
366
|
+
if (ctx.hasUI === false)
|
|
367
|
+
process.stderr.write(message + "\n");
|
|
368
|
+
else
|
|
369
|
+
ctx.ui.notify(message, "warning");
|
|
370
|
+
}
|
|
371
|
+
catch { /* ended */ }
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
resetRun();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
309
377
|
const active = () => options.active?.() ?? true;
|
|
310
378
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
311
379
|
// The one reserve decision; every consumer takes its room from limits. Standalone starts at Pi's native cut point. Once a reply did
|
|
@@ -318,7 +386,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
318
386
|
const budget = host ?? { response: settingsFor().getCompactionSettings(ctx.model).reserveTokens };
|
|
319
387
|
if (!Number.isSafeInteger(budget.response) || budget.response < 0)
|
|
320
388
|
throw new Error("Invalid host request reserve");
|
|
321
|
-
const floor = host ? host.responseExact !== true : ctx.sessionManager.getBranch().
|
|
389
|
+
const floor = host ? host.responseExact !== true : replyFit(ctx.sessionManager.getBranch()).failed;
|
|
322
390
|
return { response: floor ? Math.max(budget.response, ctx.model?.maxTokens ?? 0) : budget.response };
|
|
323
391
|
};
|
|
324
392
|
const inputRoom = (response, ctx) => {
|
|
@@ -327,27 +395,27 @@ export async function installCompaction(pi, options = {}) {
|
|
|
327
395
|
lastCeiling = ceiling ?? null;
|
|
328
396
|
if (ceiling !== undefined && (!Number.isSafeInteger(ceiling) || ceiling <= 0))
|
|
329
397
|
throw new Error("Invalid active-input token ceiling");
|
|
330
|
-
return Math.min((ctx
|
|
398
|
+
return Math.min(contextWindow(ctx) - response, ceiling ?? Infinity);
|
|
331
399
|
};
|
|
332
400
|
// Room still owns every existing cut/clip attempt. Only final capacity admission uses the hard line.
|
|
333
401
|
const limits = (ctx) => {
|
|
334
402
|
const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
|
|
335
403
|
const floor = options.requestReserve ? response : requestReplyFloor(ctx.sessionManager.getBranch(), response, ctx.model?.maxTokens);
|
|
336
|
-
const hardLine = Math.min(Math.max(room, (ctx
|
|
404
|
+
const hardLine = Math.min(Math.max(room, contextWindow(ctx) - piMargin - floor), lastCeiling ?? Infinity);
|
|
337
405
|
return { response, room, hardLine, replyFloor: floor };
|
|
338
406
|
};
|
|
339
|
-
const decisionBand = (ctx, room, system) => {
|
|
340
|
-
const last =
|
|
407
|
+
const decisionBand = (ctx, room, system, current = view(ctx)) => {
|
|
408
|
+
const last = current.session.getBranch().findLast(entry => entry.type === "compaction");
|
|
341
409
|
const after = last?.type === "compaction" ? last.details?.tokensAfter : undefined;
|
|
342
410
|
return bandOf(room, cutAt, options.cutAt === undefined && typeof after === "number" && Number.isFinite(after) && after >= 0
|
|
343
411
|
? { system, tokensAfter: after, factor } : undefined);
|
|
344
412
|
};
|
|
345
413
|
const fits = (tokens, limit, ctx) => corrected(tokens, ctx) < limit;
|
|
346
414
|
// The saved conversation and a cut's quantities are counted by the one function the guard counts with (strict, as the context hook is).
|
|
347
|
-
const measureFor = (ctx) => (messages) => projectedConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy);
|
|
415
|
+
const measureFor = (ctx) => (messages) => projectedConversationTokens(applyClips(messages, clips()), ctx, options.recallPolicy);
|
|
348
416
|
// The planner counts with the same function, except that it does not fail on a summary in another format: a cut can replace one
|
|
349
417
|
// (an operator's /compact repairs a session whose saved summary is foreign). Identical to the strict one for this compactor's summaries.
|
|
350
|
-
const plannerMeasureFor = (ctx) => Object.assign((messages) => measurableConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy), { messageTokens: (message) => conversationTokens(applyClips([message], clips)) });
|
|
418
|
+
const plannerMeasureFor = (ctx) => Object.assign((messages) => measurableConversationTokens(applyClips(messages, clips()), ctx, options.recallPolicy), { messageTokens: (message) => conversationTokens(applyClips([message], clips())) });
|
|
351
419
|
const hostMessages = (ctx) => options.hostMessages?.(ctx) ?? [];
|
|
352
420
|
const toolShapes = (names) => {
|
|
353
421
|
const selected = new Set(names);
|
|
@@ -366,7 +434,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
366
434
|
// whereas our hard line is in corrected units; fitting that line alone need not leave the branch's reply floor.
|
|
367
435
|
const requestClamp = (messages, ctx) => {
|
|
368
436
|
const context = normalizeContext({ messages: convertToLlm(messages) });
|
|
369
|
-
const cap = piClamp?.(ctx.model, context, ctx.model.maxTokens);
|
|
437
|
+
const cap = piClamp?.({ ...ctx.model, contextWindow: contextWindow(ctx) }, context, ctx.model.maxTokens);
|
|
370
438
|
return { cap, leavesFloor: cap === undefined || cap >= limits(ctx).replyFloor,
|
|
371
439
|
estimate: piMath ? piMath.estimateContextTokens(context).tokens : 0 };
|
|
372
440
|
};
|
|
@@ -375,19 +443,19 @@ export async function installCompaction(pi, options = {}) {
|
|
|
375
443
|
const saved = messages.some(message => message.role === "system") ? messages :
|
|
376
444
|
[{ role: "system", content: prompt, toolsAdded: shapes, timestamp: 0 }, ...messages];
|
|
377
445
|
const declared = enabled ? historyToolDeclarations(saved, shapes, true) : saved;
|
|
378
|
-
return applyClips([...host, ...(enabled ? projectedMessages(declared, ctx, options.recallPolicy) : declared), ...incoming], clips);
|
|
446
|
+
return applyClips([...host, ...(enabled ? projectedMessages(declared, ctx, options.recallPolicy) : declared), ...incoming], clips());
|
|
379
447
|
};
|
|
380
448
|
const clampAboveRoom = (decision, ctx, room, target, total, project, replan, deepest) => {
|
|
381
449
|
if (options.requestReserve || !piClamp)
|
|
382
450
|
return decision;
|
|
383
451
|
const lastAttempt = () => {
|
|
384
|
-
const previousClips = new Map(clips);
|
|
452
|
+
const previousClips = new Map(clips());
|
|
385
453
|
const candidate = deepest();
|
|
386
454
|
if (candidate.kind === "cut" && fits(total(candidate), limits(ctx).hardLine, ctx) && requestClamp(project(candidate), ctx).leavesFloor)
|
|
387
455
|
return candidate;
|
|
388
|
-
clips.clear();
|
|
456
|
+
clips().clear();
|
|
389
457
|
for (const [id, clip] of previousClips)
|
|
390
|
-
clips.set(id, clip);
|
|
458
|
+
clips().set(id, clip);
|
|
391
459
|
return { kind: "fault", capacity: true, message: clampMessage };
|
|
392
460
|
};
|
|
393
461
|
for (let retry = 0;; retry++) {
|
|
@@ -402,7 +470,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
402
470
|
return lastAttempt();
|
|
403
471
|
// Shrink in the planner's units by Pi's measured shortfall, at most three times. Once we reach the original room,
|
|
404
472
|
// use its existing band/room policy; admission below the room is deliberately unchanged.
|
|
405
|
-
target = Math.max(room, Math.floor(target * (ctx
|
|
473
|
+
target = Math.max(room, Math.floor(target * (contextWindow(ctx) - piMargin - limits(ctx).replyFloor) / check.estimate));
|
|
406
474
|
decision = replan(target);
|
|
407
475
|
if (decision.kind === "defer" || decision.kind === "fault")
|
|
408
476
|
return lastAttempt();
|
|
@@ -412,18 +480,6 @@ export async function installCompaction(pi, options = {}) {
|
|
|
412
480
|
const isNoWork = (message) => ["Already compacted", "Nothing to compact (session too small)"].includes(message?.replace(/^Compaction failed: /, "") ?? "");
|
|
413
481
|
// A missing model is configuration, not capacity.
|
|
414
482
|
const missingModel = () => new Error("No context model for the request budget");
|
|
415
|
-
let promptCut;
|
|
416
|
-
// Where the branch ended when the prompt's cut was planned: the prompt is the entry Pi appends there. A cut that keeps nothing of the conversation
|
|
417
|
-
// before it names no entry to keep from, and read as it stands that drops the prompt too, so the model never sees it.
|
|
418
|
-
let promptCutAt = 0;
|
|
419
|
-
const keptFrom = (cut, ctx) => cut.firstKeptEntryId ||
|
|
420
|
-
ctx.sessionManager.getBranch().slice(promptCutAt).find(entry => entry.type !== "custom" || entry.customType !== "pi-tuck-capacity")?.id || "";
|
|
421
|
-
let failure;
|
|
422
|
-
let refusalReceipted = false;
|
|
423
|
-
let pendingBoundaryCommit = false;
|
|
424
|
-
// 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
|
|
425
|
-
// whole. A clip ends with its run like any failure would: the next run plans from the session as it is, where the result is no longer fresh.
|
|
426
|
-
const clips = new Map();
|
|
427
483
|
const receipt = (kind, reason, ctx) => {
|
|
428
484
|
let room = null, hardLine = null;
|
|
429
485
|
try {
|
|
@@ -436,51 +492,43 @@ export async function installCompaction(pi, options = {}) {
|
|
|
436
492
|
recorder.stop("native-attempt-failed");
|
|
437
493
|
// 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
|
|
438
494
|
// means for the session, anything else as it is (an attempt that ended with no word looked like a hang).
|
|
439
|
-
const notify = !
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
495
|
+
const previous = failure(), notify = !previous;
|
|
496
|
+
const fault = previous instanceof CommitFault ? previous : error instanceof Error ? error : new Error(String(error));
|
|
497
|
+
const receipted = run?.refusalReceipted;
|
|
498
|
+
resetRun();
|
|
499
|
+
// Keep only a terminal attempt record until Pi settles: its fresh abort controller must still see the refusal.
|
|
500
|
+
run = createRun("failed");
|
|
501
|
+
run.failure = fault;
|
|
502
|
+
run.refusalReceipted = true;
|
|
503
|
+
if (fault instanceof CommitFault)
|
|
504
|
+
commitFault = fault;
|
|
446
505
|
try {
|
|
447
|
-
if (!
|
|
448
|
-
|
|
449
|
-
receipt("refusal", failure.message, ctx);
|
|
450
|
-
}
|
|
506
|
+
if (!receipted)
|
|
507
|
+
receipt("refusal", fault.message, ctx);
|
|
451
508
|
options.fail?.(error);
|
|
452
509
|
}
|
|
453
510
|
finally {
|
|
454
511
|
try {
|
|
455
512
|
if (notify)
|
|
456
|
-
ctx.ui.notify(
|
|
457
|
-
? `pi-tuck: ${
|
|
458
|
-
: `pi-tuck could not plan this request (${
|
|
513
|
+
ctx.ui.notify(fault instanceof CapacityError ? refusalNotice(fault.message, factor) : fault instanceof CommitFault
|
|
514
|
+
? `pi-tuck: ${fault.message}; the session holds a cut its file may lack, so start a new session.`
|
|
515
|
+
: `pi-tuck could not plan this request (${fault.message}); the next prompt is planned afresh.`, "error");
|
|
459
516
|
}
|
|
460
517
|
finally {
|
|
461
518
|
ctx.abort();
|
|
462
519
|
}
|
|
463
520
|
}
|
|
464
521
|
};
|
|
465
|
-
// The one place a failure is dropped: when its attempt has settled, when a prompt arrives, and when the operator asks for a cut. A run that no
|
|
466
|
-
// input started (a custom message) has only the first of these. No failure but a CommitFault outlives its attempt.
|
|
467
|
-
const forgetFailure = () => { refusalReceipted = false; if (!(failure instanceof CommitFault))
|
|
468
|
-
failure = undefined; };
|
|
469
522
|
const access = () => {
|
|
470
|
-
if (failure)
|
|
471
|
-
throw failure;
|
|
523
|
+
if (failure())
|
|
524
|
+
throw failure();
|
|
472
525
|
hostAccess();
|
|
473
526
|
};
|
|
474
|
-
// Abort alone is insufficient: Pi starts a fresh abort controller after
|
|
475
|
-
// before_agent_start. Keep this attempt blocked, then recheck on new idle input.
|
|
476
|
-
// "decided": `input` has decided for the run it starts, so that run's before_agent_start does not decide again. "starting": a run is
|
|
477
|
-
// between before_agent_start and agent_start, where Pi 1.1 still reports idle but a message joins that run.
|
|
478
|
-
let phase = "idle";
|
|
479
527
|
pi.on("input", async (event, ctx) => {
|
|
480
|
-
if (!ctx.isIdle() || phase === "starting")
|
|
481
|
-
return; //
|
|
482
|
-
|
|
483
|
-
|
|
528
|
+
if (!ctx.isIdle() || run?.phase === "starting")
|
|
529
|
+
return; // steering joins the existing run
|
|
530
|
+
run = createRun("decided");
|
|
531
|
+
const decision = run;
|
|
484
532
|
try {
|
|
485
533
|
syncHistoryTools(ctx);
|
|
486
534
|
await preflight(ctx, incomingTokens(event.text, event.images), [{ role: "user", content: [{ type: "text", text: event.text }, ...(event.images ?? [])], timestamp: 0 }], ctx.getSystemPrompt(), pi.getActiveTools());
|
|
@@ -489,6 +537,10 @@ export async function installCompaction(pi, options = {}) {
|
|
|
489
537
|
catch (error) {
|
|
490
538
|
fail(error, ctx);
|
|
491
539
|
}
|
|
540
|
+
// Pi normally reaches its pre-run threshold check within microtasks. Auth I/O or a later extension's input I/O can make a
|
|
541
|
+
// real prompt expire too; safely fall back to carrying its cut in the request and saving it at the first boundary.
|
|
542
|
+
setImmediate(() => { if (run === decision && !decision.claimed)
|
|
543
|
+
decision.expired = true; });
|
|
492
544
|
});
|
|
493
545
|
pi.registerTool({
|
|
494
546
|
name: "tuck_recall", label: "pi-tuck history", parameters: RecallInput,
|
|
@@ -566,28 +618,30 @@ export async function installCompaction(pi, options = {}) {
|
|
|
566
618
|
}
|
|
567
619
|
// Pi checks its own threshold after a prompt's input and before the run starts: the prompt's cut, planned with the prompt counted,
|
|
568
620
|
// is the answer, and it is saved before the first request.
|
|
569
|
-
if (
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
621
|
+
if (run?.phase === "decided" && !run.expired && event.reason === "threshold") {
|
|
622
|
+
run.claimed = true;
|
|
623
|
+
if (view(ctx).pending) {
|
|
624
|
+
access();
|
|
625
|
+
const current = view(ctx), { type: _, ...compaction } = run.cut.take();
|
|
626
|
+
return { compaction: { ...compaction, firstKeptEntryId: current.keptFromId } };
|
|
627
|
+
}
|
|
574
628
|
}
|
|
575
629
|
const operatorManual = event.reason === "manual";
|
|
576
|
-
if (operatorManual &&
|
|
577
|
-
|
|
630
|
+
if (operatorManual && run?.phase !== "admitted")
|
|
631
|
+
resetRun();
|
|
578
632
|
access();
|
|
579
633
|
// pi-tuck's summary is built from the session itself, so text after /compact cannot steer it. The operator asked for a cut: make the one
|
|
580
634
|
// plain /compact makes and say the text was not used, rather than cancel (which Pi shows as an error, with no cut).
|
|
581
635
|
if (event.customInstructions?.trim())
|
|
582
636
|
ctx.ui.notify("pi-tuck: /compact ignores the text after it, since pi-tuck builds its summary from the session itself. Cutting as plain /compact does.", "info");
|
|
583
637
|
const { response, room } = limits(ctx);
|
|
584
|
-
const retain = operatorManual ||
|
|
638
|
+
const retain = operatorManual || run?.phase === "admitted";
|
|
585
639
|
// A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
|
|
586
640
|
// cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
|
|
587
641
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
588
642
|
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
589
643
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
590
|
-
const mandatory = system + host, band = decisionBand(ctx, room, system);
|
|
644
|
+
const mandatory = system + host, band = decisionBand(ctx, room, system, view(ctx, event.reason === "threshold"));
|
|
591
645
|
const before = plannerMeasureFor(ctx)(saved);
|
|
592
646
|
observeBudget(before + mandatory, ctx);
|
|
593
647
|
// Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
|
|
@@ -610,9 +664,9 @@ export async function installCompaction(pi, options = {}) {
|
|
|
610
664
|
const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
|
|
611
665
|
// 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
|
|
612
666
|
// from any run that ended. A retry is inside its run.
|
|
613
|
-
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual ||
|
|
667
|
+
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || run?.phase !== "admitted", run?.phase === "admitted" ? protectedPrompt(ctx) : undefined);
|
|
614
668
|
return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve,
|
|
615
|
-
messages:
|
|
669
|
+
messages: plannedView(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before }).session.buildSessionProjection().messages }
|
|
616
670
|
: { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
617
671
|
},
|
|
618
672
|
});
|
|
@@ -621,7 +675,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
621
675
|
const { hardLine } = limits(ctx);
|
|
622
676
|
if (!operatorManual && hardLine > room && decision.kind === "fault") {
|
|
623
677
|
decision = fits(before + mandatory, hardLine, ctx) ? { kind: "none" } : decide(hardLine, hardLine);
|
|
624
|
-
decision = clampAboveRoom(decision, ctx, room, hardLine, candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory, candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, [], ctx.getSystemPrompt(), pi.getActiveTools(), candidate.kind === "cut"), target => target <= room ? decide() : decide(target, target), () => deepestDecision(ctx, operatorManual ||
|
|
678
|
+
decision = clampAboveRoom(decision, ctx, room, hardLine, candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory, candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, [], ctx.getSystemPrompt(), pi.getActiveTools(), candidate.kind === "cut"), target => target <= room ? decide() : decide(target, target), () => deepestDecision(ctx, operatorManual || run?.phase !== "admitted", retain));
|
|
625
679
|
}
|
|
626
680
|
if (decision.kind === "cut") {
|
|
627
681
|
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 }) };
|
|
@@ -647,49 +701,23 @@ export async function installCompaction(pi, options = {}) {
|
|
|
647
701
|
}
|
|
648
702
|
});
|
|
649
703
|
pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
|
|
650
|
-
pi.on("session_start", () => {
|
|
651
|
-
const historyToolsEnabled = (ctx) =>
|
|
704
|
+
pi.on("session_start", () => { resetRun(); recorder.stop("session-transition"); proposalId = undefined; commitFault = undefined; estimatedTokens = 0; identity = ""; factor = 1; learnedContextWindow = undefined; windowObservation = undefined; });
|
|
705
|
+
const historyToolsEnabled = (ctx) => view(ctx).hasTuckCut;
|
|
652
706
|
const syncHistoryTools = (ctx) => { if (active())
|
|
653
707
|
applyHistoryTools(pi, historyToolsEnabled(ctx)); };
|
|
654
708
|
pi.on("session_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
655
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
|
|
656
710
|
// may not be on this one.
|
|
657
|
-
pi.on("session_tree", (_event, ctx) => {
|
|
658
|
-
pi.on("session_compact", (_event, ctx) => { if (
|
|
711
|
+
pi.on("session_tree", (_event, ctx) => { resetRun(); windowObservation = undefined; learnedContextWindow = seedContextWindow(ctx.sessionManager.getBranch(), identityFor(ctx)); syncHistoryTools(ctx); });
|
|
712
|
+
pi.on("session_compact", (_event, ctx) => { if (run?.phase !== "admitted")
|
|
659
713
|
syncHistoryTools(ctx); });
|
|
660
714
|
pi.on("turn_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
661
715
|
pi.on("agent_settled", (_event, ctx) => syncHistoryTools(ctx));
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
pi.on("agent_start", () => { lengthStop = false; });
|
|
667
|
-
pi.on("message_end", event => { if (event.message.role === "assistant")
|
|
668
|
-
lengthStop = event.message.stopReason === "length"; });
|
|
669
|
-
pi.on("agent_settled", (_event, ctx) => {
|
|
670
|
-
if (!lengthStop || !active())
|
|
671
|
-
return;
|
|
672
|
-
lengthStop = false;
|
|
673
|
-
const message = "pi-tuck: the last reply stopped at the model's output limit and may be cut off. Ask the agent to continue.";
|
|
674
|
-
try {
|
|
675
|
-
if (ctx.hasUI === false)
|
|
676
|
-
process.stderr.write(message + "\n");
|
|
677
|
-
else
|
|
678
|
-
ctx.ui.notify(message, "warning");
|
|
679
|
-
}
|
|
680
|
-
catch { /* ended */ } // with no UI (pi -p), stderr is the operator's
|
|
681
|
-
});
|
|
682
|
-
// 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.
|
|
683
|
-
const previewCut = (ctx, cut) => {
|
|
684
|
-
const header = ctx.sessionManager.getHeader();
|
|
685
|
-
if (!header)
|
|
686
|
-
throw new Error("Missing native session header for the prompt's cut");
|
|
687
|
-
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
688
|
-
preview.appendCompaction(cut.summary, cut.firstKeptEntryId, 0, cut.details, true);
|
|
689
|
-
return preview;
|
|
716
|
+
const plannedView = (ctx, cut) => {
|
|
717
|
+
const pending = new PendingCut();
|
|
718
|
+
pending.plan(cut, ctx.sessionManager.getBranch().length);
|
|
719
|
+
return pending.view(ctx);
|
|
690
720
|
};
|
|
691
|
-
const cutSession = (ctx, cut) => previewCut(ctx, { ...cut, firstKeptEntryId: keptFrom(cut, ctx) });
|
|
692
|
-
const withCut = (ctx, cut) => cutSession(ctx, cut).buildSessionProjection().messages;
|
|
693
721
|
// The shared final planner: preserve the compiler's prompt/protocol floor and spend nothing on optional history. Fresh text results
|
|
694
722
|
// get the smallest existing head/tail + recall-locator representation; their originals remain in the session, as for ordinary clips.
|
|
695
723
|
const deepestDecision = (ctx, newPrompt, retain) => {
|
|
@@ -712,7 +740,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
712
740
|
low = middle + 1;
|
|
713
741
|
}
|
|
714
742
|
for (const clip of clipFor([result], entryIdOf, low))
|
|
715
|
-
clips.set(clip.toolCallId, clip);
|
|
743
|
+
clips().set(clip.toolCallId, clip);
|
|
716
744
|
}
|
|
717
745
|
}
|
|
718
746
|
const before = measureFor(ctx)(saved);
|
|
@@ -721,7 +749,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
721
749
|
protectedPromptEntryId: newPrompt ? undefined : protectedPrompt(ctx), deepest: true }, ctx, 0, plannerMeasureFor(ctx), options.recallPolicy, retain);
|
|
722
750
|
if (!draft.compaction)
|
|
723
751
|
return { kind: "fault", capacity: Boolean(draft.capacityRejected), message: draft.unavailable };
|
|
724
|
-
const messages =
|
|
752
|
+
const messages = plannedView(ctx, { type: "compaction", ...draft.compaction }).session.buildSessionProjection().messages;
|
|
725
753
|
assertNativeToolRounds(messages);
|
|
726
754
|
return { kind: "cut", target: "room", plan: { draft: true, after: measureFor(ctx)(messages), mode: draft.mode,
|
|
727
755
|
compaction: draft.compaction, reserve: 0, messages } };
|
|
@@ -731,8 +759,8 @@ export async function installCompaction(pi, options = {}) {
|
|
|
731
759
|
// keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
|
|
732
760
|
// Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
|
|
733
761
|
const preflight = (ctx, incomingPart, incomingMessages, observedPrompt, tools, hostSnapshot) => {
|
|
734
|
-
|
|
735
|
-
|
|
762
|
+
run.cut.clear();
|
|
763
|
+
const branchLength = ctx.sessionManager.getBranch().length;
|
|
736
764
|
if (!active())
|
|
737
765
|
return;
|
|
738
766
|
access();
|
|
@@ -767,7 +795,9 @@ export async function installCompaction(pi, options = {}) {
|
|
|
767
795
|
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
|
|
768
796
|
if (!draft.compaction)
|
|
769
797
|
return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
770
|
-
const
|
|
798
|
+
const candidate = new PendingCut();
|
|
799
|
+
candidate.plan({ type: "compaction", ...draft.compaction, tokensBefore: before }, branchLength);
|
|
800
|
+
const messages = candidate.view(ctx).session.buildSessionProjection().messages;
|
|
771
801
|
assertNativeToolRounds(messages);
|
|
772
802
|
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve, messages };
|
|
773
803
|
},
|
|
@@ -787,16 +817,19 @@ export async function installCompaction(pi, options = {}) {
|
|
|
787
817
|
if (decision.kind === "fault")
|
|
788
818
|
throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
|
|
789
819
|
const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, "prompt", before, false, false, previousSummary, { response, host, mandatory, target: decision.target, margin: room - band });
|
|
790
|
-
|
|
820
|
+
run.cut.plan({ type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId, tokensBefore: before, details: compaction.details }, branchLength);
|
|
791
821
|
};
|
|
792
822
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
793
823
|
// A decided plan is for the prompt `input` saw. A run that a custom message starts is not that run (another extension handled the
|
|
794
824
|
// input, so no run started for it): plan afresh.
|
|
795
|
-
const prompted = phase === "decided" && event.messageType !== "custom";
|
|
796
|
-
phase
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
825
|
+
const prompted = run?.phase === "decided" && event.messageType !== "custom";
|
|
826
|
+
if (!prompted && run?.phase !== "failed")
|
|
827
|
+
run = createRun("starting");
|
|
828
|
+
run ??= createRun("starting");
|
|
829
|
+
run.claimed = true;
|
|
830
|
+
run.phase = "starting";
|
|
831
|
+
const prompt = event.messageType === "custom" ? textOf(event.message.content) : event.prompt;
|
|
832
|
+
run.prompt = prompt ? new StartingPrompt(prompt, ctx.sessionManager.getBranch().length, event.messageType) : undefined;
|
|
800
833
|
try {
|
|
801
834
|
syncHistoryTools(ctx);
|
|
802
835
|
if (prompted)
|
|
@@ -821,7 +854,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
821
854
|
// handlers have run. Pi's prepareRequest has not yet snapshotted executable tools. A first cut discovered in context_with_system
|
|
822
855
|
// would be too late for that snapshot, even if its declarations were repaired there, so make the late prompt decision here.
|
|
823
856
|
pi.on("message_end", (event, ctx) => {
|
|
824
|
-
if (event.message.role !== "user" || !active() ||
|
|
857
|
+
if (event.message.role !== "user" || !active() || run?.phase !== "admitted" || view(ctx).pending)
|
|
825
858
|
return;
|
|
826
859
|
try {
|
|
827
860
|
access();
|
|
@@ -876,7 +909,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
876
909
|
if (forecast !== saved) {
|
|
877
910
|
const head = getCurrentSystemMessage(convertToLlm(forecast));
|
|
878
911
|
const native = (head ? estimateTokens(head) : 0) + saved.reduce((sum, message) => sum + (message.role === "system" ? 0 : estimateTokens(message)), 0);
|
|
879
|
-
if (shouldCompact(native, ctx
|
|
912
|
+
if (shouldCompact(native, contextWindow(ctx), settingsFor().getCompactionSettings(ctx.model)))
|
|
880
913
|
densityTokens = native;
|
|
881
914
|
}
|
|
882
915
|
}
|
|
@@ -914,12 +947,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
914
947
|
}
|
|
915
948
|
return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
916
949
|
}
|
|
917
|
-
const
|
|
918
|
-
if (!header)
|
|
919
|
-
throw new Error("Missing native session header at tool-loop boundary");
|
|
920
|
-
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
921
|
-
preview.appendCompaction(draft.compaction.summary, draft.compaction.firstKeptEntryId, before, draft.compaction.details, true);
|
|
922
|
-
const messages = preview.buildSessionProjection().messages;
|
|
950
|
+
const messages = plannedView(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before }).session.buildSessionProjection().messages;
|
|
923
951
|
assertNativeToolRounds(messages);
|
|
924
952
|
previews.push(messages);
|
|
925
953
|
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve, messages };
|
|
@@ -944,11 +972,11 @@ export async function installCompaction(pi, options = {}) {
|
|
|
944
972
|
if (!round.length)
|
|
945
973
|
continue;
|
|
946
974
|
for (const clip of round)
|
|
947
|
-
clips.set(clip.toolCallId, clip);
|
|
975
|
+
run.clips.set(clip.toolCallId, clip);
|
|
948
976
|
const clippedDecision = decide(clipRoom, targetBand, required);
|
|
949
977
|
if (clippedDecision.kind === "fault")
|
|
950
978
|
for (const clip of round)
|
|
951
|
-
clips.delete(clip.toolCallId);
|
|
979
|
+
run.clips.delete(clip.toolCallId);
|
|
952
980
|
else {
|
|
953
981
|
decision = clippedDecision;
|
|
954
982
|
break;
|
|
@@ -985,56 +1013,57 @@ export async function installCompaction(pi, options = {}) {
|
|
|
985
1013
|
return { entries: [{ type: "compaction", summary: compaction.summary,
|
|
986
1014
|
firstKeptEntryId: compaction.firstKeptEntryId, details: compaction.details }] };
|
|
987
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
|
+
};
|
|
988
1030
|
const atBoundary = (event, ctx, reason, decide) => {
|
|
989
|
-
|
|
1031
|
+
const current = view(ctx);
|
|
1032
|
+
if (!current.pending)
|
|
990
1033
|
return decide ? boundaryCut(event, ctx, reason) : undefined;
|
|
991
|
-
const entry =
|
|
992
|
-
|
|
993
|
-
const preview = cutSession(ctx, entry), view = Object.create(ctx, { sessionManager: { value: preview } });
|
|
1034
|
+
const entry = run.cut.take();
|
|
1035
|
+
const preview = current.session, context = Object.create(ctx, { sessionManager: { value: preview } });
|
|
994
1036
|
const more = decide && !event.entries.length
|
|
995
|
-
? boundaryCut({ ...event, context: { ...event.context, contextMessages: preview.buildSessionProjection().messages } },
|
|
1037
|
+
? boundaryCut({ ...event, context: { ...event.context, contextMessages: preview.buildSessionProjection().messages } }, context, reason) : undefined;
|
|
996
1038
|
const { tokensBefore: _, ...cut } = entry;
|
|
997
|
-
const saved = { ...cut, firstKeptEntryId:
|
|
1039
|
+
const saved = { ...cut, firstKeptEntryId: current.keptFromId };
|
|
998
1040
|
// As for a cut Pi makes (session_compact): the history the cut leaves behind is indexed, once Pi has committed the entries.
|
|
999
1041
|
setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
|
|
1000
1042
|
return { entries: [...event.entries, saved, ...(more?.entries ?? [])] };
|
|
1001
1043
|
};
|
|
1002
|
-
const
|
|
1044
|
+
const retryLength = (event, ctx) => {
|
|
1003
1045
|
// Pi's supported boundary drafts commit before finishTurn honors `continue`. Omitting the selected failed response also prevents
|
|
1004
1046
|
// Pi's later native length recovery from retrying it independently. The stored message and its output usage stay on the branch.
|
|
1005
1047
|
const entries = [...(atBoundary(event, ctx, "run-end", false)?.entries ?? event.entries),
|
|
1006
1048
|
{ type: "context_edit", targetId: event.messageEntryId, replacement: null }];
|
|
1007
|
-
if (thinkingRetryPending)
|
|
1049
|
+
if (run.thinkingRetryPending)
|
|
1008
1050
|
return { entries, continue: false };
|
|
1009
|
-
const
|
|
1010
|
-
if (!header)
|
|
1011
|
-
throw new Error("Missing native session header for the thinking-only retry");
|
|
1012
|
-
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
1013
|
-
for (const entry of entries) {
|
|
1014
|
-
if (entry.type === "context_edit")
|
|
1015
|
-
preview.appendContextEdit(entry.targetId, entry.replacement);
|
|
1016
|
-
else if (entry.type === "compaction")
|
|
1017
|
-
preview.appendCompaction(entry.summary, entry.firstKeptEntryId, 0, entry.details, true, entry.usage);
|
|
1018
|
-
else if (entry.type === "custom")
|
|
1019
|
-
preview.appendCustomEntry(entry.customType, entry.data);
|
|
1020
|
-
else
|
|
1021
|
-
preview.appendCustomMessageEntry(entry.customType, entry.content, entry.display, entry.details);
|
|
1022
|
-
}
|
|
1051
|
+
const preview = liveCut.view(ctx, entries).session;
|
|
1023
1052
|
const view = Object.create(ctx, { sessionManager: { value: preview } });
|
|
1024
1053
|
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
1025
1054
|
// The deepest cut only when it buys reply room: where Pi's cap on the request as it stands (the stopped reply left out) is already
|
|
1026
1055
|
// as large as the deepest cut's, the turn is retried uncut, and no history is given up for nothing.
|
|
1027
1056
|
const asIs = projectedRequest(preview.buildSessionProjection().messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), false);
|
|
1028
1057
|
const asIsCheck = requestClamp(asIs, view);
|
|
1029
|
-
const previousClips = new Map(clips);
|
|
1058
|
+
const previousClips = new Map(clips());
|
|
1030
1059
|
const decision = deepestDecision(view, false, true);
|
|
1031
|
-
if (decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
|
|
1060
|
+
if (!serverClipped.has(event.message) && decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
|
|
1032
1061
|
requestClamp(projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true), view).cap <= asIsCheck.cap) {
|
|
1033
|
-
clips.clear();
|
|
1062
|
+
clips().clear();
|
|
1034
1063
|
for (const [id, clip] of previousClips)
|
|
1035
|
-
clips.set(id, clip);
|
|
1064
|
+
clips().set(id, clip);
|
|
1036
1065
|
const { room, hardLine } = limits(view);
|
|
1037
|
-
thinkingRetryPending = true;
|
|
1066
|
+
run.thinkingRetryPending = true;
|
|
1038
1067
|
return { entries: [...entries, { type: "custom", customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
|
|
1039
1068
|
stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: false,
|
|
1040
1069
|
piEstimateTokens: asIsCheck.estimate, piCap: asIsCheck.cap, room, hardLine, buildId: BUILD_ID } }], continue: true };
|
|
@@ -1042,35 +1071,39 @@ export async function installCompaction(pi, options = {}) {
|
|
|
1042
1071
|
if (decision.kind !== "cut")
|
|
1043
1072
|
throw new CapacityError(clampMessage);
|
|
1044
1073
|
const messages = projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true);
|
|
1045
|
-
if (
|
|
1074
|
+
if (run?.prompt && !carriesPrompt(messages, run.prompt.text(ctx.sessionManager.getBranch()), run.prompt.role))
|
|
1046
1075
|
throw promptRefusal();
|
|
1047
1076
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
|
|
1048
1077
|
const tokens = system + conversationTokens(messages), check = requestClamp(messages, view);
|
|
1049
1078
|
const { response, room, hardLine } = limits(view);
|
|
1050
|
-
|
|
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)
|
|
1051
1083
|
throw new CapacityError(clampMessage);
|
|
1052
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) });
|
|
1053
|
-
thinkingRetryPending = true;
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1085
|
+
run.thinkingRetryPending = true;
|
|
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 };
|
|
1059
1092
|
};
|
|
1060
1093
|
pi.on("turn_end", (event, ctx) => {
|
|
1061
1094
|
try {
|
|
1062
|
-
if (!active() ||
|
|
1095
|
+
if (!active() || run?.phase !== "admitted")
|
|
1063
1096
|
return;
|
|
1064
|
-
if (thinkingOnlyLength(event.message) && !failure && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
|
|
1065
|
-
return
|
|
1066
|
-
thinkingRetryPending = false;
|
|
1097
|
+
if ((thinkingOnlyLength(event.message) || serverClipped.has(event.message)) && !failure() && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
|
|
1098
|
+
return retryLength(event, ctx);
|
|
1099
|
+
run.thinkingRetryPending = false;
|
|
1067
1100
|
const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
|
|
1068
1101
|
settingsFor().getCompactionSettings(ctx.model).enabled;
|
|
1069
|
-
if (!
|
|
1102
|
+
if (!view(ctx).pending && !decide)
|
|
1070
1103
|
return;
|
|
1071
1104
|
const result = atBoundary(event, ctx, "tool-loop", decide);
|
|
1072
1105
|
if (result) {
|
|
1073
|
-
|
|
1106
|
+
markBoundary(result.entries);
|
|
1074
1107
|
}
|
|
1075
1108
|
return result;
|
|
1076
1109
|
}
|
|
@@ -1091,23 +1124,24 @@ export async function installCompaction(pi, options = {}) {
|
|
|
1091
1124
|
// 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
|
|
1092
1125
|
// the conversation with the cut applied. Pi sends the saved system message apart from the request's messages, so neither side carries it
|
|
1093
1126
|
// here. A request that does not carry the saved conversation as it stands cannot take the cut.
|
|
1094
|
-
const cutRequest = (messages, ctx
|
|
1127
|
+
const cutRequest = (messages, ctx) => {
|
|
1095
1128
|
const conversation = (all) => all.filter(message => message.role !== "system");
|
|
1096
1129
|
const saved = conversation(ctx.sessionManager.buildSessionProjection().messages);
|
|
1097
1130
|
for (let at = 0; at + saved.length <= messages.length; at++)
|
|
1098
1131
|
if (saved.every((message, i) => isDeepStrictEqual(messages[at + i], message)))
|
|
1099
|
-
return [...messages.slice(0, at), ...conversation(
|
|
1132
|
+
return [...messages.slice(0, at), ...conversation(view(ctx).session.buildSessionProjection().messages), ...messages.slice(at + saved.length)];
|
|
1100
1133
|
throw new Error("The prompt's cut cannot be applied: the request does not carry the saved conversation");
|
|
1101
1134
|
};
|
|
1102
1135
|
pi.on("context", (event, ctx) => {
|
|
1103
1136
|
try {
|
|
1104
1137
|
if (!active())
|
|
1105
1138
|
return;
|
|
1139
|
+
run?.prompt?.id(ctx.sessionManager.getBranch());
|
|
1106
1140
|
// Before a cut, Pi owns the whole request, including its object identities and system-message serialization.
|
|
1107
1141
|
if (!historyToolsEnabled(ctx))
|
|
1108
|
-
return clips.size ? { messages: applyClips(event.messages, clips) } : undefined;
|
|
1142
|
+
return clips().size ? { messages: applyClips(event.messages, clips()) } : undefined;
|
|
1109
1143
|
access();
|
|
1110
|
-
return { messages: applyClips(projectedMessages(
|
|
1144
|
+
return { messages: applyClips(projectedMessages(view(ctx).pending ? cutRequest(event.messages, ctx) : event.messages, ctx, options.recallPolicy), clips()) };
|
|
1111
1145
|
}
|
|
1112
1146
|
catch (error) {
|
|
1113
1147
|
try {
|
|
@@ -1138,17 +1172,27 @@ export async function installCompaction(pi, options = {}) {
|
|
|
1138
1172
|
fail(new (event.fromExtension ? CommitFault : Error)(event.errorMessage ?? "Compaction failed"), ctx);
|
|
1139
1173
|
});
|
|
1140
1174
|
pi.on("agent_before_settle", (event, ctx) => {
|
|
1141
|
-
if (pendingBoundaryCommit) {
|
|
1142
|
-
if (event.outcome === "error")
|
|
1143
|
-
|
|
1144
|
-
|
|
1175
|
+
if (run?.pendingBoundaryCommit) {
|
|
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) {
|
|
1186
|
+
run.pendingBoundaryCommit = false;
|
|
1187
|
+
run.pendingBoundaryReceipts = [];
|
|
1188
|
+
}
|
|
1145
1189
|
}
|
|
1146
1190
|
// Pi commits these entries before the run settles.
|
|
1147
1191
|
try {
|
|
1148
|
-
if (!active() ||
|
|
1192
|
+
if (!active() || run?.phase !== "admitted" || failure() || !settingsFor().getCompactionSettings(ctx.model).enabled)
|
|
1149
1193
|
return;
|
|
1150
1194
|
const decide = event.outcome === "completed" && !ctx.signal?.aborted;
|
|
1151
|
-
if (
|
|
1195
|
+
if (view(ctx).pending ? event.outcome === "error" : !decide)
|
|
1152
1196
|
return;
|
|
1153
1197
|
return atBoundary(event, ctx, "run-end", decide);
|
|
1154
1198
|
}
|
|
@@ -1158,7 +1202,10 @@ export async function installCompaction(pi, options = {}) {
|
|
|
1158
1202
|
});
|
|
1159
1203
|
pi.on("context_with_system", (event, ctx) => {
|
|
1160
1204
|
// Reaching the next request boundary proves native commit returned, not durable storage.
|
|
1161
|
-
|
|
1205
|
+
if (run) {
|
|
1206
|
+
run.pendingBoundaryCommit = false;
|
|
1207
|
+
run.pendingBoundaryReceipts = [];
|
|
1208
|
+
}
|
|
1162
1209
|
pending = undefined;
|
|
1163
1210
|
try {
|
|
1164
1211
|
if (!active())
|
|
@@ -1177,7 +1224,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
1177
1224
|
observeBudget(tokens, ctx);
|
|
1178
1225
|
estimatedTokens = corrected(tokens, ctx);
|
|
1179
1226
|
const { room, hardLine } = limits(ctx);
|
|
1180
|
-
if (
|
|
1227
|
+
if (run?.prompt && !carriesPrompt(messages, run.prompt.text(ctx.sessionManager.getBranch()), run.prompt.role))
|
|
1181
1228
|
throw new Refusal("Prompt check failed: the request is missing the whole text of the prompt that started this run. Shorten the prompt or choose a model with a larger context window, then retry");
|
|
1182
1229
|
if (!fits(tokens, hardLine, ctx))
|
|
1183
1230
|
throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
|