pi-tuck 1.0.24 → 1.0.25
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 +31 -0
- package/README.md +3 -2
- 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 +217 -41
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/request-capacity.d.ts +5 -1
- package/dist/src/request-capacity.js +12 -3
- package/dist/src/status.d.ts +2 -0
- package/dist/src/status.js +3 -0
- package/dist/src/tuck.d.ts +1 -0
- package/dist/src/tuck.js +8 -0
- package/package.json +1 -1
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +207 -49
- package/src/request-capacity.ts +15 -4
- package/src/status.ts +4 -1
- package/src/tuck.ts +9 -1
package/dist/src/status.d.ts
CHANGED
package/dist/src/status.js
CHANGED
|
@@ -21,6 +21,8 @@ export function statusText(input) {
|
|
|
21
21
|
const cutLine = ours.length === 0 ? "Cuts: none yet in this session. The recall tools appear after the first cut."
|
|
22
22
|
: `Cuts: ${ours.length} by pi-tuck in this session` + (typeof before === "number" && typeof after === "number" ? `, the last from ${count(before)} to ${count(after)} tokens.` : ".");
|
|
23
23
|
const native = cuts.length - ours.length;
|
|
24
|
+
const retries = input.branch.filter(entry => entry.type === "custom" && entry.customType === "pi-tuck-capacity" &&
|
|
25
|
+
entry.data?.kind === "retry").length;
|
|
24
26
|
const searchLine = !input.search ? "Search: by keyword (search by meaning needs macOS on Apple silicon)."
|
|
25
27
|
: input.semantic.state === "ready" || input.semantic.state === "waiting" ? "Search: by meaning and keyword."
|
|
26
28
|
: input.semantic.state === "preparing" ? "Search: by meaning and keyword; indexing history in the background."
|
|
@@ -28,6 +30,7 @@ export function statusText(input) {
|
|
|
28
30
|
return [
|
|
29
31
|
`pi-tuck ${input.version} (the version this session loaded; restart Pi after an update).`,
|
|
30
32
|
cutLine,
|
|
33
|
+
...(retries ? [`Retries: ${retries} after thinking-only length stops in this session.`] : []),
|
|
31
34
|
...(native > 0 ? [`Pi's own compaction cut ${native} time${native === 1 ? "" : "s"} in this session.`] : []),
|
|
32
35
|
input.cutAt === undefined ? "Cut point: adapts by itself." : `Cut point: fixed at cutAt ${input.cutAt} of the input room. Remove cutAt from pi-tuck.json to let it adapt.`,
|
|
33
36
|
searchLine,
|
package/dist/src/tuck.d.ts
CHANGED
|
@@ -87,6 +87,7 @@ export declare function tuckObservedDraft(tuck: Tuck, input: {
|
|
|
87
87
|
newPrompt?: boolean;
|
|
88
88
|
reRenderSummary?: boolean;
|
|
89
89
|
protectedPromptEntryId?: string;
|
|
90
|
+
deepest?: boolean;
|
|
90
91
|
}, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean): Draft;
|
|
91
92
|
/** Numeric references use the original session index, then intersect with readable current-branch entries. */
|
|
92
93
|
export declare function tuckRecall(tuck: Tuck, entries: readonly SessionEntry[], branch: readonly SessionEntry[], input: RecallOptions & {
|
package/dist/src/tuck.js
CHANGED
|
@@ -311,6 +311,14 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
311
311
|
};
|
|
312
312
|
const calibrationHigh = highAt(floorBoundary, minimumOpeningLength);
|
|
313
313
|
const floorSummary = render(calibrationHigh.rows), floorProjection = preview(floorSummary, floorBoundary);
|
|
314
|
+
// The last admission attempt and thinking-only retry share this exact floor: whole protected prompts, the smallest
|
|
315
|
+
// existing summary representation, and the required fresh/native suffix. No historical fill or room target spends reply space.
|
|
316
|
+
if (input.deepest) {
|
|
317
|
+
assertNativeToolRounds(floorProjection);
|
|
318
|
+
const after = measure(floorProjection);
|
|
319
|
+
return { compaction: { summary: floorSummary, firstKeptEntryId: entries[floorBoundary]?.id ?? "", tokensBefore: input.tokensBefore,
|
|
320
|
+
details: { ...detailsAt(floorBoundary), tokensAfter: after, carriedTarget: after, view: "shrunk-dialogue", mode: "shrunk", deepest: true } }, after, mode: "shrunk" };
|
|
321
|
+
}
|
|
314
322
|
const offset = measure(floorProjection) - nativeAt(floorBoundary) - summaryTokens(floorSummary.length);
|
|
315
323
|
const price = (characters, boundary) => offset + nativeAt(boundary) + summaryTokens(characters);
|
|
316
324
|
const listCache = new Map();
|
package/package.json
CHANGED
package/src/build-id.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "48d3f777f203edb56b29154215540eb8245182237d18cdc50c6cbe704ea28e7e";
|
package/src/compaction.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { textOf } from "./tuck/core/content.js";
|
|
2
|
-
import { requestReplyFloor, carriesPrompt, piContextSafetyMargin } from "./request-capacity.js";
|
|
2
|
+
import { requestReplyFloor, carriesPrompt, needsFullReplyReserve, thinkingOnlyLength, piContextSafetyMargin } from "./request-capacity.js";
|
|
3
3
|
import { BUILD_ID } from "./build-id.js";
|
|
4
4
|
import { createSemanticRecall, budgetSemantic, type SemanticRecallController } from "./semantic-recall";
|
|
5
5
|
import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry, type Receipt, type RecorderOptions } from "./diagnostics.js";
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { exportSource } from "./source-export.js";
|
|
8
8
|
import { isDeepStrictEqual } from "node:util";
|
|
9
|
-
import { assertNativeToolRounds } from "./protocol.js";
|
|
9
|
+
import { assertNativeToolRounds, isDiscardedResponse } from "./protocol.js";
|
|
10
10
|
import { projectedMessages } from "./omissions.js";
|
|
11
11
|
import { applyClips, clipBudgets, clipFor, type Clip } from "./clip.js";
|
|
12
12
|
import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt,
|
|
13
|
-
type AgentMessage, type
|
|
13
|
+
type AgentMessage, type Decision, type Messages, type Plan, type Target } from "./budget.js";
|
|
14
14
|
import { getCurrentSystemMessage, isContextOverflow, normalizeContext, type ImageContent, type ToolResultMessage } from "@earendil-works/pi-ai";
|
|
15
|
-
import {
|
|
15
|
+
import { estimateContextTokens } from "@earendil-works/pi-ai/utils/estimate";
|
|
16
|
+
import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry, type SessionBoundaryDraft, type TurnEndEvent } from "@earendil-works/pi-coding-agent";
|
|
16
17
|
import { Type } from "typebox";
|
|
17
18
|
import { Check } from "typebox/value";
|
|
18
19
|
import type { RecallPolicy } from "./recall.js";
|
|
@@ -116,7 +117,8 @@ export interface CompactionOptions {
|
|
|
116
117
|
cutAt?: number;
|
|
117
118
|
/** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
|
|
118
119
|
* 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.
|
|
119
|
-
* After a length stop or context overflow it holds the full reserve, also raised to the catalogue maximum.
|
|
120
|
+
* After a length stop with text/tool output or context overflow it holds the full reserve, also raised to the catalogue maximum.
|
|
121
|
+
* Thinking-only exhaustion keeps Pi's reserve and requires the branch's largest observed reply instead.
|
|
120
122
|
* Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
|
|
121
123
|
requestReserve?: (ctx: ExtensionContext) => { response: number; responseExact?: true };
|
|
122
124
|
/** The exact messages the host's `context` hook adds to every final request beyond the saved conversation (for example a role
|
|
@@ -134,7 +136,7 @@ export interface CompactionOptions {
|
|
|
134
136
|
|
|
135
137
|
export async function installCompaction(pi: ExtensionAPI, options: CompactionOptions = {}) {
|
|
136
138
|
const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin();
|
|
137
|
-
const piClamp =
|
|
139
|
+
const piClamp = await import('@earendil-works/pi-ai/api/simple-options')
|
|
138
140
|
.then(module => module.clampMaxTokensToContext).catch(() => undefined);
|
|
139
141
|
const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
|
|
140
142
|
if (!validCutAt(cutAt)) throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
|
|
@@ -219,6 +221,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
219
221
|
// Pi's native start/settled events bracket the run and its post-turn cuts.
|
|
220
222
|
let admittedRun = false;
|
|
221
223
|
let runPrompt: string | undefined;
|
|
224
|
+
let thinkingRetryPending = false;
|
|
222
225
|
const protectedPrompt = (ctx: ExtensionContext) => runPrompt ? ctx.sessionManager.getBranch().findLast(entry =>
|
|
223
226
|
entry.type === "message" && entry.message.role === "user" && textOf(entry.message.content).includes(runPrompt!))?.id : undefined;
|
|
224
227
|
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");
|
|
@@ -280,13 +283,14 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
280
283
|
// 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.
|
|
281
284
|
if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
|
|
282
285
|
});
|
|
283
|
-
pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
|
|
284
|
-
pi.on("agent_settled", () => { admittedRun = false; runPrompt = undefined; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
|
|
286
|
+
pi.on("agent_start", () => { admittedRun = true; thinkingRetryPending = false; pending = undefined; phase = "idle"; });
|
|
287
|
+
pi.on("agent_settled", () => { admittedRun = false; runPrompt = undefined; thinkingRetryPending = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
|
|
285
288
|
const active = () => options.active?.() ?? true;
|
|
286
289
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
287
290
|
// The one reserve decision; every consumer takes its room from limits. Standalone starts at Pi's native cut point. Once a reply did
|
|
288
|
-
// not fit (a length stop, or a refusal pi-ai classes as a context overflow), hold the catalogue maximum too.
|
|
289
|
-
//
|
|
291
|
+
// not fit (a length stop with text/tools, or a refusal pi-ai classes as a context overflow), hold the catalogue maximum too. A thinking-only
|
|
292
|
+
// length stop uses branchReplyFloor's observed output instead; reserving the catalogue maximum would strand setups above the room.
|
|
293
|
+
// Other errors, such as a 503, keep native's room. Read the whole branch, including history before compactions, each time: no flag to persist or reset on resume,
|
|
290
294
|
// fork, /tree or a new session.
|
|
291
295
|
const requestReserve = (ctx: ExtensionContext) => {
|
|
292
296
|
const host = options.requestReserve?.(ctx);
|
|
@@ -294,8 +298,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
294
298
|
if (!Number.isSafeInteger(budget.response) || budget.response < 0)
|
|
295
299
|
throw new Error("Invalid host request reserve");
|
|
296
300
|
const floor = host ? host.responseExact !== true : ctx.sessionManager.getBranch().some(entry =>
|
|
297
|
-
entry.type === "message" && entry.message
|
|
298
|
-
(entry.message.stopReason === "length" || (entry.message.stopReason === "error" && isContextOverflow(entry.message))));
|
|
301
|
+
entry.type === "message" && needsFullReplyReserve(entry.message));
|
|
299
302
|
return { response: floor ? Math.max(budget.response, ctx.model?.maxTokens ?? 0) : budget.response };
|
|
300
303
|
};
|
|
301
304
|
const inputRoom = (response: number, ctx: ExtensionContext) => {
|
|
@@ -327,8 +330,8 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
327
330
|
const plannerMeasureFor = (ctx: ExtensionContext) => Object.assign(
|
|
328
331
|
(messages: Messages) => measurableConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy),
|
|
329
332
|
{ messageTokens: (message: Messages[number]) => conversationTokens(applyClips([message], clips)) });
|
|
330
|
-
const
|
|
331
|
-
const toolShapes = (names: Iterable<string>)
|
|
333
|
+
const hostMessages = (ctx: ExtensionContext) => options.hostMessages?.(ctx) ?? [];
|
|
334
|
+
const toolShapes = (names: Iterable<string>) => {
|
|
332
335
|
const selected = new Set(names);
|
|
333
336
|
return pi.getAllTools().filter(tool => selected.has(tool.name)).map(tool => ({ name: tool.name, description: tool.description, parameters: tool.parameters }));
|
|
334
337
|
};
|
|
@@ -340,6 +343,46 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
340
343
|
const future = saved.some(message => message.role === "system") ? historyToolDeclarations(saved, shapes, true) : saved;
|
|
341
344
|
return systemPart(observedPrompt, shapes, future, guardPrompt);
|
|
342
345
|
};
|
|
346
|
+
const clampMessage = "Pi's request clamp leaves less than the reply floor";
|
|
347
|
+
// Both the guard and the early projections ask Pi about the very same converted context. Pi's cap uses its own estimator,
|
|
348
|
+
// whereas our hard line is in corrected units; fitting that line alone need not leave the branch's reply floor.
|
|
349
|
+
const requestClamp = (messages: Messages, ctx: ExtensionContext) => {
|
|
350
|
+
const context = normalizeContext({ messages: convertToLlm(messages) });
|
|
351
|
+
const cap = piClamp?.(ctx.model!, context, ctx.model!.maxTokens);
|
|
352
|
+
return { cap, leavesFloor: cap === undefined || cap >= limits(ctx).replyFloor,
|
|
353
|
+
estimate: estimateContextTokens(context).tokens };
|
|
354
|
+
};
|
|
355
|
+
const projectedRequest = (messages: Messages, ctx: ExtensionContext, host: Messages, incoming: Messages, prompt: string, tools: readonly string[], cut: boolean) => {
|
|
356
|
+
const enabled = cut || historyToolsEnabled(ctx), shapes = toolShapes(historyToolNames(pi, tools, enabled));
|
|
357
|
+
const saved = messages.some(message => message.role === "system") ? messages :
|
|
358
|
+
[{ role: "system" as const, content: prompt, toolsAdded: shapes, timestamp: 0 }, ...messages];
|
|
359
|
+
const declared = enabled ? historyToolDeclarations(saved, shapes, true) : saved;
|
|
360
|
+
return applyClips([...host, ...(enabled ? projectedMessages(declared, ctx, options.recallPolicy) : declared), ...incoming], clips);
|
|
361
|
+
};
|
|
362
|
+
const clampAboveRoom = <D extends object>(decision: Decision<D>, ctx: ExtensionContext, room: number, target: number,
|
|
363
|
+
total: (decision: Decision<D>) => number, project: (decision: Decision<D>) => Messages, replan: (target: number) => Decision<D>, deepest: () => Decision<D>): Decision<D> => {
|
|
364
|
+
if (options.requestReserve || !piClamp) return decision;
|
|
365
|
+
const lastAttempt = (): Decision<D> => {
|
|
366
|
+
const previousClips = new Map(clips);
|
|
367
|
+
const candidate = deepest();
|
|
368
|
+
if (candidate.kind === "cut" && fits(total(candidate), limits(ctx).hardLine, ctx) && requestClamp(project(candidate), ctx).leavesFloor)
|
|
369
|
+
return candidate;
|
|
370
|
+
clips.clear(); for (const [id, clip] of previousClips) clips.set(id, clip);
|
|
371
|
+
return { kind: "fault", capacity: true, message: clampMessage };
|
|
372
|
+
};
|
|
373
|
+
for (let retry = 0; ; retry++) {
|
|
374
|
+
if (decision.kind !== "cut" && decision.kind !== "none") return decision;
|
|
375
|
+
if (corrected(total(decision), ctx) <= room) return decision;
|
|
376
|
+
const check = requestClamp(project(decision), ctx);
|
|
377
|
+
if (check.leavesFloor) return decision;
|
|
378
|
+
if (retry === 3) return lastAttempt();
|
|
379
|
+
// Shrink in the planner's units by Pi's measured shortfall, at most three times. Once we reach the original room,
|
|
380
|
+
// use its existing band/room policy; admission below the room is deliberately unchanged.
|
|
381
|
+
target = Math.max(room, Math.floor(target * (ctx.model!.contextWindow - piMargin - limits(ctx).replyFloor) / check.estimate));
|
|
382
|
+
decision = replan(target);
|
|
383
|
+
if (decision.kind === "defer" || decision.kind === "fault") return lastAttempt();
|
|
384
|
+
}
|
|
385
|
+
};
|
|
343
386
|
// Pi reports a manual cut with nothing to do as a failed compaction; the thrown error lacks the event's "Compaction failed: " prefix.
|
|
344
387
|
const isNoWork = (message: string | undefined) => ["Already compacted", "Nothing to compact (session too small)"].includes(message?.replace(/^Compaction failed: /, "") ?? "");
|
|
345
388
|
// A missing model is configuration, not capacity.
|
|
@@ -347,6 +390,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
347
390
|
// A prompt's cut, until its run saves it at its first boundary: every request of that run carries it (the context hook). A run that ends
|
|
348
391
|
// before a boundary drops it, and the next prompt decides afresh.
|
|
349
392
|
type CutEntry = { type: "compaction"; summary: string; firstKeptEntryId: string; tokensBefore: number; details?: unknown };
|
|
393
|
+
type CutPlan = { compaction: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>; reserve: number; messages: Messages };
|
|
350
394
|
let promptCut: CutEntry | undefined;
|
|
351
395
|
// 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
|
|
352
396
|
// before it names no entry to keep from, and read as it stands that drops the prompt too, so the model never sees it.
|
|
@@ -400,7 +444,8 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
400
444
|
if (!ctx.isIdle() || phase === "starting") return; // a steering or follow-up message joins the running request
|
|
401
445
|
forgetFailure();
|
|
402
446
|
phase = "decided";
|
|
403
|
-
try { syncHistoryTools(ctx); await preflight(ctx, incomingTokens(event.text, event.images),
|
|
447
|
+
try { syncHistoryTools(ctx); await preflight(ctx, incomingTokens(event.text, event.images),
|
|
448
|
+
[{ role: "user", content: [{ type: "text", text: event.text }, ...(event.images ?? [])], timestamp: 0 }], ctx.getSystemPrompt(), pi.getActiveTools()); syncHistoryTools(ctx); }
|
|
404
449
|
catch (error) { fail(error, ctx); }
|
|
405
450
|
});
|
|
406
451
|
pi.registerTool({
|
|
@@ -473,7 +518,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
473
518
|
// A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
|
|
474
519
|
// cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
|
|
475
520
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
476
|
-
const
|
|
521
|
+
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
477
522
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
478
523
|
const mandatory = system + host, band = decisionBand(ctx, room, system);
|
|
479
524
|
const before = plannerMeasureFor(ctx)(saved);
|
|
@@ -493,26 +538,31 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
493
538
|
before, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
494
539
|
opportunistic: thresholdOpportunistic,
|
|
495
540
|
unreduced: "Compaction did not reduce context within the estimated request budget",
|
|
496
|
-
plan: (target): Plan<
|
|
541
|
+
plan: (target): Plan<CutPlan> => {
|
|
497
542
|
const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
|
|
498
543
|
// 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
|
|
499
544
|
// from any run that ended. A retry is inside its run.
|
|
500
545
|
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun, admittedRun ? protectedPrompt(ctx) : undefined);
|
|
501
|
-
return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve
|
|
546
|
+
return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve,
|
|
547
|
+
messages: previewCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before }).buildSessionProjection().messages }
|
|
502
548
|
: { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
503
549
|
},
|
|
504
550
|
});
|
|
505
551
|
estimatedTokens = corrected(before + mandatory, ctx);
|
|
506
|
-
let decision = decide();
|
|
552
|
+
let decision: Decision<CutPlan> = decide();
|
|
507
553
|
const { hardLine } = limits(ctx);
|
|
508
554
|
if (!operatorManual && hardLine > room && decision.kind === "fault") {
|
|
509
|
-
|
|
510
|
-
decision =
|
|
555
|
+
decision = fits(before + mandatory, hardLine, ctx) ? { kind: "none" } : decide(hardLine, hardLine);
|
|
556
|
+
decision = clampAboveRoom(decision, ctx, room, hardLine,
|
|
557
|
+
candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory,
|
|
558
|
+
candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, [], ctx.getSystemPrompt(), pi.getActiveTools(), candidate.kind === "cut"),
|
|
559
|
+
target => target <= room ? decide() : decide(target, target),
|
|
560
|
+
() => deepestDecision(ctx, operatorManual || !admittedRun, retain));
|
|
511
561
|
}
|
|
512
562
|
if (decision.kind === "cut") { return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore,
|
|
513
563
|
retain, operatorManual, event.preparation.previousSummary, { response, host, mandatory, target: decision.target, margin: room - band }) }; }
|
|
514
564
|
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
515
|
-
if (decision.kind === "none") return { cancel: true };
|
|
565
|
+
if (decision.kind === "none" || decision.kind === "defer") return { cancel: true };
|
|
516
566
|
recorder.stop("no-cut");
|
|
517
567
|
ctx.ui.notify(decision.message, "warning");
|
|
518
568
|
if (operatorManual) receipt("refusal", decision.message, ctx);
|
|
@@ -533,20 +583,63 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
533
583
|
pi.on("session_compact", (_event, ctx) => { if (!admittedRun) syncHistoryTools(ctx); });
|
|
534
584
|
pi.on("turn_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
535
585
|
pi.on("agent_settled", (_event, ctx) => syncHistoryTools(ctx));
|
|
586
|
+
// A reply that stopped at the output limit may be cut off, and Pi says nothing. Tell the operator once per run, on the operator's side only:
|
|
587
|
+
// the notice never enters the model's context. Decided at the end of the run, so a later reply in the same run (a retry or a continuation)
|
|
588
|
+
// that ends normally clears it.
|
|
589
|
+
let lengthStop = false;
|
|
590
|
+
pi.on("agent_start", () => { lengthStop = false; });
|
|
591
|
+
pi.on("message_end", event => { if (event.message.role === "assistant") lengthStop = event.message.stopReason === "length"; });
|
|
592
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
593
|
+
if (!lengthStop || !active()) return;
|
|
594
|
+
lengthStop = false;
|
|
595
|
+
const message = "pi-tuck: the last reply stopped at the model's output limit and may be cut off. Ask the agent to continue.";
|
|
596
|
+
try { if (ctx.hasUI === false) process.stderr.write(message + "\n"); else ctx.ui.notify(message, "warning"); } catch { /* ended */ } // with no UI (pi -p), stderr is the operator's
|
|
597
|
+
});
|
|
536
598
|
// 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.
|
|
537
|
-
const
|
|
599
|
+
const previewCut = (ctx: ExtensionContext, cut: CutEntry) => {
|
|
538
600
|
const header = ctx.sessionManager.getHeader();
|
|
539
601
|
if (!header) throw new Error("Missing native session header for the prompt's cut");
|
|
540
602
|
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
541
|
-
preview.appendCompaction(cut.summary,
|
|
603
|
+
preview.appendCompaction(cut.summary, cut.firstKeptEntryId, 0, cut.details, true);
|
|
542
604
|
return preview;
|
|
543
605
|
};
|
|
606
|
+
const cutSession = (ctx: ExtensionContext, cut: CutEntry) => previewCut(ctx, { ...cut, firstKeptEntryId: keptFrom(cut, ctx) });
|
|
544
607
|
const withCut = (ctx: ExtensionContext, cut: CutEntry) => cutSession(ctx, cut).buildSessionProjection().messages;
|
|
608
|
+
// The shared final planner: preserve the compiler's prompt/protocol floor and spend nothing on optional history. Fresh text results
|
|
609
|
+
// get the smallest existing head/tail + recall-locator representation; their originals remain in the session, as for ordinary clips.
|
|
610
|
+
const deepestDecision = (ctx: ExtensionContext, newPrompt: boolean, retain: boolean): Decision<CutPlan> => {
|
|
611
|
+
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
612
|
+
if (!newPrompt) {
|
|
613
|
+
const at = saved.findLastIndex(message => message.role === "assistant" && !isDiscardedResponse(message));
|
|
614
|
+
const response = saved[at];
|
|
615
|
+
const calls = new Set(response?.role === "assistant" ? response.content.flatMap(block => block.type === "toolCall" ? [block.id] : []) : []);
|
|
616
|
+
const results = saved.slice(at + 1).filter((message): message is ToolResultMessage => message.role === "toolResult" && calls.has(message.toolCallId));
|
|
617
|
+
const entryIdOf = (id: string) => ctx.sessionManager.getBranch().findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === id)?.id;
|
|
618
|
+
for (const result of results) {
|
|
619
|
+
let low = 0, high = conversationTokens([result]) - 1;
|
|
620
|
+
if (!clipFor([result], entryIdOf, high).length) continue;
|
|
621
|
+
while (low < high) {
|
|
622
|
+
const middle = Math.floor((low + high) / 2);
|
|
623
|
+
if (clipFor([result], entryIdOf, middle).length) high = middle; else low = middle + 1;
|
|
624
|
+
}
|
|
625
|
+
for (const clip of clipFor([result], entryIdOf, low)) clips.set(clip.toolCallId, clip);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const before = measureFor(ctx)(saved);
|
|
629
|
+
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before,
|
|
630
|
+
previousSummary: saved.find(message => message.role === "compactionSummary")?.summary, newPrompt, reRenderSummary: true,
|
|
631
|
+
protectedPromptEntryId: newPrompt ? undefined : protectedPrompt(ctx), deepest: true }, ctx, 0, plannerMeasureFor(ctx), options.recallPolicy, retain);
|
|
632
|
+
if (!draft.compaction) return { kind: "fault", capacity: Boolean(draft.capacityRejected), message: draft.unavailable };
|
|
633
|
+
const messages = previewCut(ctx, { type: "compaction", ...draft.compaction }).buildSessionProjection().messages;
|
|
634
|
+
assertNativeToolRounds(messages);
|
|
635
|
+
return { kind: "cut", target: "room", plan: { draft: true, after: measureFor(ctx)(messages), mode: draft.mode,
|
|
636
|
+
compaction: draft.compaction, reserve: 0, messages } };
|
|
637
|
+
};
|
|
545
638
|
// The cut before a run, decided on `input` while the session is idle; before_agent_start decides only for a run no input started (one a
|
|
546
639
|
// custom message starts). The cut is pi-tuck's own, planned as at a tool boundary. It is not Pi's: Pi's cut finder keeps the last
|
|
547
640
|
// keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
|
|
548
641
|
// Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
|
|
549
|
-
const preflight = (ctx: ExtensionContext, incomingPart: number, observedPrompt: string, tools: readonly string[], hostSnapshot?:
|
|
642
|
+
const preflight = (ctx: ExtensionContext, incomingPart: number, incomingMessages: Messages, observedPrompt: string, tools: readonly string[], hostSnapshot?: Messages) => {
|
|
550
643
|
promptCut = undefined;
|
|
551
644
|
promptCutAt = ctx.sessionManager.getBranch().length;
|
|
552
645
|
if (!active()) return;
|
|
@@ -558,7 +651,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
558
651
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
559
652
|
const system = requestSystemPart(observedPrompt, tools, saved, ctx.getSystemPrompt());
|
|
560
653
|
const band = decisionBand(ctx, room, system);
|
|
561
|
-
const
|
|
654
|
+
const hostInput = hostSnapshot ?? hostMessages(ctx), host = conversationTokens(hostInput), measure = measureFor(ctx), before = measure(saved);
|
|
562
655
|
const incoming = incomingPart + system, mandatory = incoming + host;
|
|
563
656
|
observeBudget(before + mandatory, ctx);
|
|
564
657
|
estimatedTokens = corrected(before + mandatory, ctx);
|
|
@@ -572,7 +665,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
572
665
|
const decide = (targetRoom: number, targetBand: number, opportunistic: boolean) => chooseCut({
|
|
573
666
|
before, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic,
|
|
574
667
|
unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
|
|
575
|
-
plan: (target): Plan<
|
|
668
|
+
plan: (target): Plan<CutPlan> => {
|
|
576
669
|
const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
|
|
577
670
|
// Only a due cut re-renders a summary that sits alone in the session: inside the band a cut is opportunistic, and a smaller summary
|
|
578
671
|
// there buys nothing the request needed and costs history the next recall would have found.
|
|
@@ -580,14 +673,19 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
580
673
|
if (!draft.compaction) return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
581
674
|
const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
|
|
582
675
|
assertNativeToolRounds(messages);
|
|
583
|
-
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
676
|
+
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve, messages };
|
|
584
677
|
},
|
|
585
678
|
});
|
|
586
|
-
let decision = gate.kind === "plan" ? decide(room, band, gate.opportunistic) : { kind: "defer"
|
|
679
|
+
let decision: Decision<CutPlan> = gate.kind === "plan" ? decide(room, band, gate.opportunistic) : { kind: "defer" };
|
|
587
680
|
const { hardLine } = limits(ctx);
|
|
588
681
|
if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
|
|
589
|
-
|
|
590
|
-
|
|
682
|
+
decision = fits(before + mandatory, hardLine, ctx) ? { kind: "none" } :
|
|
683
|
+
fits(mandatory, hardLine, ctx) ? decide(hardLine, hardLine, false) : { kind: "defer" };
|
|
684
|
+
decision = clampAboveRoom(decision, ctx, room, hardLine,
|
|
685
|
+
candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory,
|
|
686
|
+
candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, incomingMessages, observedPrompt, tools, candidate.kind === "cut"),
|
|
687
|
+
target => decide(target, target <= room ? band : target, false),
|
|
688
|
+
() => deepestDecision(ctx, true, false));
|
|
591
689
|
}
|
|
592
690
|
if (decision.kind === "defer") return;
|
|
593
691
|
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
@@ -606,7 +704,8 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
606
704
|
try {
|
|
607
705
|
syncHistoryTools(ctx);
|
|
608
706
|
if (prompted) access(); // decided on input; a failure latched there still stops the run
|
|
609
|
-
else await preflight(ctx, incomingEventTokens(event),
|
|
707
|
+
else await preflight(ctx, incomingEventTokens(event), [{ role: "user", timestamp: 0,
|
|
708
|
+
content: event.messageType === "custom" ? event.message.content : [{ type: "text", text: event.prompt }, ...(event.images ?? [])] }], event.systemPrompt,
|
|
610
709
|
historyToolNames(pi, event.systemPromptOptions.selectedTools, historyToolsEnabled(ctx)));
|
|
611
710
|
// Pi declares tool changes before turn_start, then snapshots executable tools in prepareRequest. Introduce our tools in
|
|
612
711
|
// the request projection: recording an activation message here would shift every later session-global recall index.
|
|
@@ -629,10 +728,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
629
728
|
const incoming = incomingTokens(typeof message.content === "string" ? message.content : message.content.flatMap(b => b.type === "text" ? [b.text] : []).join("\n"),
|
|
630
729
|
typeof message.content === "string" ? [] : message.content.filter((b): b is ImageContent => b.type === "image"));
|
|
631
730
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
632
|
-
const
|
|
731
|
+
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
633
732
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
634
733
|
if (fits(conversationTokens(saved) + system + incoming + host, limits(ctx).room, ctx)) return;
|
|
635
|
-
preflight(ctx, incoming, ctx.getSystemPrompt(), pi.getActiveTools(),
|
|
734
|
+
preflight(ctx, incoming, [message], ctx.getSystemPrompt(), pi.getActiveTools(), hostInput);
|
|
636
735
|
syncHistoryTools(ctx);
|
|
637
736
|
} catch (error) { fail(error, ctx); }
|
|
638
737
|
});
|
|
@@ -654,7 +753,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
654
753
|
// request while tools still run, so it can be counted early: conservative, and nothing else would cut at that later boundary.
|
|
655
754
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
656
755
|
const band = decisionBand(ctx, room, system);
|
|
657
|
-
const
|
|
756
|
+
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
658
757
|
const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages as Messages) : 0;
|
|
659
758
|
const mandatory = system + host + queued;
|
|
660
759
|
// Without usable provider usage, Pi's own threshold uses its unwrapped message estimate as the compiler's density input.
|
|
@@ -680,17 +779,18 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
680
779
|
let floorKept: number | undefined;
|
|
681
780
|
let wholePromptFloor: number | undefined;
|
|
682
781
|
const protectedId = reason === "tool-loop" ? protectedPrompt(ctx) : undefined;
|
|
683
|
-
const decide = (targetRoom = room, targetBand = band) => {
|
|
782
|
+
const decide = (targetRoom = room, targetBand = band, required = false) => {
|
|
684
783
|
previews = [];
|
|
685
784
|
floorKept = undefined;
|
|
686
785
|
wholePromptFloor = undefined;
|
|
687
786
|
const before = measure(saved);
|
|
688
|
-
const gate =
|
|
787
|
+
const gate = required && fits(mandatory, targetRoom, ctx) ? { kind: "plan" as const, opportunistic: false } :
|
|
788
|
+
gateCut({ total: before + mandatory, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
689
789
|
if (gate.kind !== "plan") return gate;
|
|
690
790
|
return chooseCut({
|
|
691
791
|
opportunistic: gate.opportunistic, mandatory, before, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
692
792
|
unreduced: "Tool-loop preflight compaction did not reduce context within the estimated request budget",
|
|
693
|
-
plan: (target): Plan<
|
|
793
|
+
plan: (target): Plan<CutPlan> => {
|
|
694
794
|
const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
|
|
695
795
|
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: densityTokens ?? before, previousSummary, protectedPromptEntryId: protectedId }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
|
|
696
796
|
if (!draft.compaction) {
|
|
@@ -712,17 +812,17 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
712
812
|
const messages = preview.buildSessionProjection().messages;
|
|
713
813
|
assertNativeToolRounds(messages);
|
|
714
814
|
previews.push(messages);
|
|
715
|
-
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
815
|
+
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve, messages };
|
|
716
816
|
},
|
|
717
817
|
});
|
|
718
818
|
};
|
|
719
|
-
let decision = decide();
|
|
819
|
+
let decision: Decision<CutPlan> = decide();
|
|
720
820
|
// Only a request that would be refused is clipped. A fresh result is kept whole by every cut (it has not reached the model yet), so a round
|
|
721
821
|
// larger than the room refuses the run; in its place the request carries the result's head and tail with a tuck_recall locator to the rest.
|
|
722
822
|
// clipBudgets states the budgets tried and why they are counted in the estimate's units; `kept` is measured from the previews this decision
|
|
723
823
|
// built, so the second budget is the cut's own floor and not a new formula. When nothing is clipped or the decision still faults, the
|
|
724
824
|
// refusal stands.
|
|
725
|
-
const tryClips = (clipRoom: number, targetBand: number) => {
|
|
825
|
+
const tryClips = (clipRoom: number, targetBand: number, required = false) => {
|
|
726
826
|
if (decision.kind !== "fault" || !event.toolResults) return;
|
|
727
827
|
const branch = ctx.sessionManager.getBranch();
|
|
728
828
|
const entryIdOf = (toolCallId: string) => branch.findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId)?.id;
|
|
@@ -733,7 +833,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
733
833
|
const round = clipFor(event.toolResults, entryIdOf, budget);
|
|
734
834
|
if (!round.length) continue;
|
|
735
835
|
for (const clip of round) clips.set(clip.toolCallId, clip);
|
|
736
|
-
const clippedDecision = decide(clipRoom, targetBand);
|
|
836
|
+
const clippedDecision = decide(clipRoom, targetBand, required);
|
|
737
837
|
if (clippedDecision.kind === "fault") for (const clip of round) clips.delete(clip.toolCallId);
|
|
738
838
|
else { decision = clippedDecision; break; }
|
|
739
839
|
}
|
|
@@ -744,9 +844,17 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
744
844
|
estimatedTokens = corrected(before + mandatory, ctx);
|
|
745
845
|
const { hardLine } = limits(ctx);
|
|
746
846
|
if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
|
|
747
|
-
if (fits(before + mandatory, hardLine, ctx))
|
|
748
|
-
decision = decide(hardLine, hardLine);
|
|
749
|
-
|
|
847
|
+
if (fits(before + mandatory, hardLine, ctx)) decision = { kind: "none" };
|
|
848
|
+
else { decision = decide(hardLine, hardLine); tryClips(hardLine, hardLine); }
|
|
849
|
+
decision = clampAboveRoom(decision, ctx, room, hardLine,
|
|
850
|
+
candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory,
|
|
851
|
+
candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, event.context.pendingMessages as Messages, ctx.getSystemPrompt(), pi.getActiveTools(), candidate.kind === "cut"),
|
|
852
|
+
target => {
|
|
853
|
+
const targetBand = target <= room ? band : target;
|
|
854
|
+
decision = decide(target, targetBand, target > room);
|
|
855
|
+
tryClips(target, targetBand, target > room);
|
|
856
|
+
return decision;
|
|
857
|
+
}, () => deepestDecision(ctx, false, true));
|
|
750
858
|
}
|
|
751
859
|
if (decision.kind === "fault" && reason === "tool-loop") {
|
|
752
860
|
if (wholePromptFloor !== undefined && !fits(wholePromptFloor, hardLine, ctx)) throw promptRefusal();
|
|
@@ -774,9 +882,60 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
774
882
|
setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
|
|
775
883
|
return { entries: [...event.entries as CutEntry[], saved, ...(more?.entries ?? [])] };
|
|
776
884
|
};
|
|
885
|
+
const retryThinking = (event: TurnEndEvent, ctx: ExtensionContext) => {
|
|
886
|
+
// Pi's supported boundary drafts commit before finishTurn honors `continue`. Omitting the selected failed response also prevents
|
|
887
|
+
// Pi's later native length recovery from retrying it independently. The stored message and its output usage stay on the branch.
|
|
888
|
+
const entries: SessionBoundaryDraft[] = [...(atBoundary(event, ctx, "run-end", false)?.entries ?? event.entries),
|
|
889
|
+
{ type: "context_edit", targetId: event.messageEntryId, replacement: null }];
|
|
890
|
+
if (thinkingRetryPending) return { entries, continue: false };
|
|
891
|
+
const header = ctx.sessionManager.getHeader();
|
|
892
|
+
if (!header) throw new Error("Missing native session header for the thinking-only retry");
|
|
893
|
+
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
894
|
+
for (const entry of entries) {
|
|
895
|
+
if (entry.type === "context_edit") preview.appendContextEdit(entry.targetId, entry.replacement);
|
|
896
|
+
else if (entry.type === "compaction") preview.appendCompaction(entry.summary, entry.firstKeptEntryId, 0, entry.details, true, entry.usage);
|
|
897
|
+
else if (entry.type === "custom") preview.appendCustomEntry(entry.customType, entry.data);
|
|
898
|
+
else preview.appendCustomMessageEntry(entry.customType, entry.content, entry.display, entry.details);
|
|
899
|
+
}
|
|
900
|
+
const view: ExtensionContext = Object.create(ctx, { sessionManager: { value: preview } });
|
|
901
|
+
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
902
|
+
// 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
|
|
903
|
+
// as large as the deepest cut's, the turn is retried uncut, and no history is given up for nothing.
|
|
904
|
+
const asIs = projectedRequest(preview.buildSessionProjection().messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), false);
|
|
905
|
+
const asIsCheck = requestClamp(asIs, view);
|
|
906
|
+
const previousClips = new Map(clips);
|
|
907
|
+
const decision = deepestDecision(view, false, true);
|
|
908
|
+
if (decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
|
|
909
|
+
requestClamp(projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true), view).cap! <= asIsCheck.cap) {
|
|
910
|
+
clips.clear(); for (const [id, clip] of previousClips) clips.set(id, clip);
|
|
911
|
+
const { room, hardLine } = limits(view);
|
|
912
|
+
thinkingRetryPending = true;
|
|
913
|
+
return { entries: [...entries, { type: "custom" as const, customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
|
|
914
|
+
stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: false,
|
|
915
|
+
piEstimateTokens: asIsCheck.estimate, piCap: asIsCheck.cap, room, hardLine, buildId: BUILD_ID } }], continue: true };
|
|
916
|
+
}
|
|
917
|
+
if (decision.kind !== "cut") throw new CapacityError(clampMessage);
|
|
918
|
+
const messages = projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true);
|
|
919
|
+
if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt)) throw promptRefusal();
|
|
920
|
+
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
|
|
921
|
+
const tokens = system + conversationTokens(messages), check = requestClamp(messages, view);
|
|
922
|
+
const { response, room, hardLine } = limits(view);
|
|
923
|
+
if (!fits(tokens, hardLine, view) || check.cap === undefined || !check.leavesFloor) throw new CapacityError(clampMessage);
|
|
924
|
+
const compaction = captureDraft(decision.plan.compaction, view, decision.plan.reserve, "length-retry", tokens, true, false, undefined,
|
|
925
|
+
{ response, host, mandatory: system + host + conversationTokens(event.context.pendingMessages), target: decision.target, margin: room - decisionBand(view, room, system) });
|
|
926
|
+
thinkingRetryPending = true;
|
|
927
|
+
pendingBoundaryCommit = true;
|
|
928
|
+
return { entries: [...entries, { type: "compaction" as const, summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId || null, details: compaction.details },
|
|
929
|
+
{ type: "custom" as const, customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
|
|
930
|
+
stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: true,
|
|
931
|
+
estimatedTokens: corrected(tokens, view), piEstimateTokens: check.estimate, piCap: check.cap, room, hardLine, buildId: BUILD_ID } }], continue: true };
|
|
932
|
+
};
|
|
777
933
|
pi.on("turn_end", (event, ctx) => {
|
|
778
934
|
try {
|
|
779
935
|
if (!active() || !admittedRun) return;
|
|
936
|
+
if (thinkingOnlyLength(event.message) && !failure && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
|
|
937
|
+
return retryThinking(event, ctx);
|
|
938
|
+
thinkingRetryPending = false;
|
|
780
939
|
const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
|
|
781
940
|
settingsFor().getCompactionSettings(ctx.model).enabled;
|
|
782
941
|
if (!promptCut && !decide) return;
|
|
@@ -860,16 +1019,15 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
860
1019
|
const tokens = system + conversationTokens(messages);
|
|
861
1020
|
observeBudget(tokens, ctx);
|
|
862
1021
|
estimatedTokens = corrected(tokens, ctx);
|
|
863
|
-
const { room, hardLine
|
|
1022
|
+
const { room, hardLine } = limits(ctx);
|
|
864
1023
|
if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
|
|
865
1024
|
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");
|
|
866
1025
|
if (!fits(tokens, hardLine, ctx))
|
|
867
1026
|
throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
|
|
868
1027
|
// Pi's request estimator and its compaction estimator need not agree. The hard line is
|
|
869
1028
|
// necessary; above the room, also require the real request clamp to preserve the floor.
|
|
870
|
-
if (!options.requestReserve && estimatedTokens > room && piClamp &&
|
|
871
|
-
|
|
872
|
-
throw new CapacityError("Pi's request clamp leaves less than the reply floor");
|
|
1029
|
+
if (!options.requestReserve && estimatedTokens > room && piClamp && !requestClamp(messages, ctx).leavesFloor)
|
|
1030
|
+
throw new CapacityError(clampMessage);
|
|
873
1031
|
if (estimatedTokens > room) receipt("above-room", "Request exceeds the room and fits the hard line", ctx);
|
|
874
1032
|
if (recorder.enabled && !recorder.waitingForCut) recorder.measureInline(() => {
|
|
875
1033
|
recorder.request();
|
package/src/request-capacity.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isContextOverflow, normalizeContext } from '@earendil-works/pi-ai';
|
|
2
2
|
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
3
|
-
import type { Messages } from './budget.js';
|
|
3
|
+
import type { AgentMessage, Messages } from './budget.js';
|
|
4
4
|
import { parseRecords, splitSummary } from './tuck/core/transcript.js';
|
|
5
5
|
|
|
6
6
|
/** Read Pi's private safety allowance through its public clamp, rather than copying it.
|
|
@@ -21,17 +21,28 @@ export async function piContextSafetyMargin(): Promise<number> {
|
|
|
21
21
|
/** The smallest reply room a request above the room must leave after Pi's own clamp: above the user's p99 reply (3,270 tokens). */
|
|
22
22
|
export const REPLY_FLOOR_MIN = 3_584;
|
|
23
23
|
|
|
24
|
+
/** A spent thinking budget that produced neither a visible answer nor work. Empty/text/tool replies keep the existing length recovery. */
|
|
25
|
+
export const thinkingOnlyLength = (message: AgentMessage): boolean => message.role === 'assistant' && message.stopReason === 'length' &&
|
|
26
|
+
message.content.length > 0 && message.content.every(block => block.type === 'thinking');
|
|
27
|
+
|
|
28
|
+
/** Thinking-only exhaustion uses the observed largest reply, not the full reserve. Every other failed length/overflow keeps its override. */
|
|
29
|
+
export const needsFullReplyReserve = (message: AgentMessage): boolean => message.role === 'assistant' &&
|
|
30
|
+
(message.stopReason === 'length' && !thinkingOnlyLength(message) || message.stopReason === 'error' && isContextOverflow(message));
|
|
31
|
+
|
|
24
32
|
/** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
|
|
25
33
|
export function branchReplyFloor(branch: readonly SessionEntry[], reserve: number): number {
|
|
26
|
-
let largest = REPLY_FLOOR_MIN;
|
|
34
|
+
let largest = REPLY_FLOOR_MIN, thinkingStop = false;
|
|
27
35
|
for (const entry of branch) {
|
|
28
36
|
if (entry.type !== 'message' || entry.message.role !== 'assistant') continue;
|
|
29
37
|
const message = entry.message;
|
|
30
|
-
if (
|
|
38
|
+
if (needsFullReplyReserve(message)) return reserve;
|
|
39
|
+
thinkingStop ||= thinkingOnlyLength(message);
|
|
31
40
|
const output = message.usage?.output;
|
|
32
41
|
if (typeof output === 'number' && Number.isFinite(output) && output > largest) largest = output;
|
|
33
42
|
}
|
|
34
|
-
|
|
43
|
+
// Include the exhausted thinking reply itself, even across later cuts/restarts. Its observed output must fit again;
|
|
44
|
+
// requestReplyFloor applies the model cap. Ordinary branches retain their existing reserve cap.
|
|
45
|
+
return thinkingStop ? Math.ceil(largest) : Math.min(reserve, Math.ceil(largest));
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
/** Admission never promises more output than a model with a positive reply cap can produce. */
|