pi-tuck 1.0.24 → 1.0.26
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 +49 -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 +218 -43
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/request-capacity.d.ts +20 -3
- package/dist/src/request-capacity.js +55 -8
- 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 +208 -51
- package/src/request-capacity.ts +53 -9
- package/src/status.ts +4 -1
- package/src/tuck.ts +9 -1
package/src/compaction.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
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, loadPiRequestMath } 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 { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry, type SessionBoundaryDraft, type TurnEndEvent } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { Type } from "typebox";
|
|
17
17
|
import { Check } from "typebox/value";
|
|
18
18
|
import type { RecallPolicy } from "./recall.js";
|
|
@@ -116,7 +116,8 @@ export interface CompactionOptions {
|
|
|
116
116
|
cutAt?: number;
|
|
117
117
|
/** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
|
|
118
118
|
* 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.
|
|
119
|
+
* After a length stop with text/tool output or context overflow it holds the full reserve, also raised to the catalogue maximum.
|
|
120
|
+
* Thinking-only exhaustion keeps Pi's reserve and requires the branch's largest observed reply instead.
|
|
120
121
|
* Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
|
|
121
122
|
requestReserve?: (ctx: ExtensionContext) => { response: number; responseExact?: true };
|
|
122
123
|
/** The exact messages the host's `context` hook adds to every final request beyond the saved conversation (for example a role
|
|
@@ -133,9 +134,9 @@ export interface CompactionOptions {
|
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
export async function installCompaction(pi: ExtensionAPI, options: CompactionOptions = {}) {
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
137
|
+
const piMath = await loadPiRequestMath();
|
|
138
|
+
const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin(piMath);
|
|
139
|
+
const piClamp = piMath?.clampMaxTokensToContext;
|
|
139
140
|
const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
|
|
140
141
|
if (!validCutAt(cutAt)) throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
|
|
141
142
|
const semantic = options.semanticRecall ?? createSemanticRecall(), search = options.search ?? true;
|
|
@@ -219,6 +220,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
219
220
|
// Pi's native start/settled events bracket the run and its post-turn cuts.
|
|
220
221
|
let admittedRun = false;
|
|
221
222
|
let runPrompt: string | undefined;
|
|
223
|
+
let thinkingRetryPending = false;
|
|
222
224
|
const protectedPrompt = (ctx: ExtensionContext) => runPrompt ? ctx.sessionManager.getBranch().findLast(entry =>
|
|
223
225
|
entry.type === "message" && entry.message.role === "user" && textOf(entry.message.content).includes(runPrompt!))?.id : undefined;
|
|
224
226
|
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 +282,14 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
280
282
|
// 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
283
|
if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
|
|
282
284
|
});
|
|
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; });
|
|
285
|
+
pi.on("agent_start", () => { admittedRun = true; thinkingRetryPending = false; pending = undefined; phase = "idle"; });
|
|
286
|
+
pi.on("agent_settled", () => { admittedRun = false; runPrompt = undefined; thinkingRetryPending = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
|
|
285
287
|
const active = () => options.active?.() ?? true;
|
|
286
288
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
287
289
|
// 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
|
-
//
|
|
290
|
+
// 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
|
|
291
|
+
// length stop uses branchReplyFloor's observed output instead; reserving the catalogue maximum would strand setups above the room.
|
|
292
|
+
// 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
293
|
// fork, /tree or a new session.
|
|
291
294
|
const requestReserve = (ctx: ExtensionContext) => {
|
|
292
295
|
const host = options.requestReserve?.(ctx);
|
|
@@ -294,8 +297,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
294
297
|
if (!Number.isSafeInteger(budget.response) || budget.response < 0)
|
|
295
298
|
throw new Error("Invalid host request reserve");
|
|
296
299
|
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))));
|
|
300
|
+
entry.type === "message" && needsFullReplyReserve(entry.message));
|
|
299
301
|
return { response: floor ? Math.max(budget.response, ctx.model?.maxTokens ?? 0) : budget.response };
|
|
300
302
|
};
|
|
301
303
|
const inputRoom = (response: number, ctx: ExtensionContext) => {
|
|
@@ -327,8 +329,8 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
327
329
|
const plannerMeasureFor = (ctx: ExtensionContext) => Object.assign(
|
|
328
330
|
(messages: Messages) => measurableConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy),
|
|
329
331
|
{ messageTokens: (message: Messages[number]) => conversationTokens(applyClips([message], clips)) });
|
|
330
|
-
const
|
|
331
|
-
const toolShapes = (names: Iterable<string>)
|
|
332
|
+
const hostMessages = (ctx: ExtensionContext) => options.hostMessages?.(ctx) ?? [];
|
|
333
|
+
const toolShapes = (names: Iterable<string>) => {
|
|
332
334
|
const selected = new Set(names);
|
|
333
335
|
return pi.getAllTools().filter(tool => selected.has(tool.name)).map(tool => ({ name: tool.name, description: tool.description, parameters: tool.parameters }));
|
|
334
336
|
};
|
|
@@ -340,6 +342,46 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
340
342
|
const future = saved.some(message => message.role === "system") ? historyToolDeclarations(saved, shapes, true) : saved;
|
|
341
343
|
return systemPart(observedPrompt, shapes, future, guardPrompt);
|
|
342
344
|
};
|
|
345
|
+
const clampMessage = "Pi's request clamp leaves less than the reply floor";
|
|
346
|
+
// Both the guard and the early projections ask Pi about the very same converted context. Pi's cap uses its own estimator,
|
|
347
|
+
// whereas our hard line is in corrected units; fitting that line alone need not leave the branch's reply floor.
|
|
348
|
+
const requestClamp = (messages: Messages, ctx: ExtensionContext) => {
|
|
349
|
+
const context = normalizeContext({ messages: convertToLlm(messages) });
|
|
350
|
+
const cap = piClamp?.(ctx.model!, context, ctx.model!.maxTokens);
|
|
351
|
+
return { cap, leavesFloor: cap === undefined || cap >= limits(ctx).replyFloor,
|
|
352
|
+
estimate: piMath ? piMath.estimateContextTokens(context).tokens : 0 };
|
|
353
|
+
};
|
|
354
|
+
const projectedRequest = (messages: Messages, ctx: ExtensionContext, host: Messages, incoming: Messages, prompt: string, tools: readonly string[], cut: boolean) => {
|
|
355
|
+
const enabled = cut || historyToolsEnabled(ctx), shapes = toolShapes(historyToolNames(pi, tools, enabled));
|
|
356
|
+
const saved = messages.some(message => message.role === "system") ? messages :
|
|
357
|
+
[{ role: "system" as const, content: prompt, toolsAdded: shapes, timestamp: 0 }, ...messages];
|
|
358
|
+
const declared = enabled ? historyToolDeclarations(saved, shapes, true) : saved;
|
|
359
|
+
return applyClips([...host, ...(enabled ? projectedMessages(declared, ctx, options.recallPolicy) : declared), ...incoming], clips);
|
|
360
|
+
};
|
|
361
|
+
const clampAboveRoom = <D extends object>(decision: Decision<D>, ctx: ExtensionContext, room: number, target: number,
|
|
362
|
+
total: (decision: Decision<D>) => number, project: (decision: Decision<D>) => Messages, replan: (target: number) => Decision<D>, deepest: () => Decision<D>): Decision<D> => {
|
|
363
|
+
if (options.requestReserve || !piClamp) return decision;
|
|
364
|
+
const lastAttempt = (): Decision<D> => {
|
|
365
|
+
const previousClips = new Map(clips);
|
|
366
|
+
const candidate = deepest();
|
|
367
|
+
if (candidate.kind === "cut" && fits(total(candidate), limits(ctx).hardLine, ctx) && requestClamp(project(candidate), ctx).leavesFloor)
|
|
368
|
+
return candidate;
|
|
369
|
+
clips.clear(); for (const [id, clip] of previousClips) clips.set(id, clip);
|
|
370
|
+
return { kind: "fault", capacity: true, message: clampMessage };
|
|
371
|
+
};
|
|
372
|
+
for (let retry = 0; ; retry++) {
|
|
373
|
+
if (decision.kind !== "cut" && decision.kind !== "none") return decision;
|
|
374
|
+
if (corrected(total(decision), ctx) <= room) return decision;
|
|
375
|
+
const check = requestClamp(project(decision), ctx);
|
|
376
|
+
if (check.leavesFloor) return decision;
|
|
377
|
+
if (retry === 3) return lastAttempt();
|
|
378
|
+
// Shrink in the planner's units by Pi's measured shortfall, at most three times. Once we reach the original room,
|
|
379
|
+
// use its existing band/room policy; admission below the room is deliberately unchanged.
|
|
380
|
+
target = Math.max(room, Math.floor(target * (ctx.model!.contextWindow - piMargin - limits(ctx).replyFloor) / check.estimate));
|
|
381
|
+
decision = replan(target);
|
|
382
|
+
if (decision.kind === "defer" || decision.kind === "fault") return lastAttempt();
|
|
383
|
+
}
|
|
384
|
+
};
|
|
343
385
|
// Pi reports a manual cut with nothing to do as a failed compaction; the thrown error lacks the event's "Compaction failed: " prefix.
|
|
344
386
|
const isNoWork = (message: string | undefined) => ["Already compacted", "Nothing to compact (session too small)"].includes(message?.replace(/^Compaction failed: /, "") ?? "");
|
|
345
387
|
// A missing model is configuration, not capacity.
|
|
@@ -347,6 +389,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
347
389
|
// 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
390
|
// before a boundary drops it, and the next prompt decides afresh.
|
|
349
391
|
type CutEntry = { type: "compaction"; summary: string; firstKeptEntryId: string; tokensBefore: number; details?: unknown };
|
|
392
|
+
type CutPlan = { compaction: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>; reserve: number; messages: Messages };
|
|
350
393
|
let promptCut: CutEntry | undefined;
|
|
351
394
|
// 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
395
|
// 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 +443,8 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
400
443
|
if (!ctx.isIdle() || phase === "starting") return; // a steering or follow-up message joins the running request
|
|
401
444
|
forgetFailure();
|
|
402
445
|
phase = "decided";
|
|
403
|
-
try { syncHistoryTools(ctx); await preflight(ctx, incomingTokens(event.text, event.images),
|
|
446
|
+
try { syncHistoryTools(ctx); await preflight(ctx, incomingTokens(event.text, event.images),
|
|
447
|
+
[{ role: "user", content: [{ type: "text", text: event.text }, ...(event.images ?? [])], timestamp: 0 }], ctx.getSystemPrompt(), pi.getActiveTools()); syncHistoryTools(ctx); }
|
|
404
448
|
catch (error) { fail(error, ctx); }
|
|
405
449
|
});
|
|
406
450
|
pi.registerTool({
|
|
@@ -473,7 +517,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
473
517
|
// A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
|
|
474
518
|
// cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
|
|
475
519
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
476
|
-
const
|
|
520
|
+
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
477
521
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
478
522
|
const mandatory = system + host, band = decisionBand(ctx, room, system);
|
|
479
523
|
const before = plannerMeasureFor(ctx)(saved);
|
|
@@ -493,26 +537,31 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
493
537
|
before, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
494
538
|
opportunistic: thresholdOpportunistic,
|
|
495
539
|
unreduced: "Compaction did not reduce context within the estimated request budget",
|
|
496
|
-
plan: (target): Plan<
|
|
540
|
+
plan: (target): Plan<CutPlan> => {
|
|
497
541
|
const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
|
|
498
542
|
// 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
543
|
// from any run that ended. A retry is inside its run.
|
|
500
544
|
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
|
|
545
|
+
return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve,
|
|
546
|
+
messages: previewCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before }).buildSessionProjection().messages }
|
|
502
547
|
: { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
503
548
|
},
|
|
504
549
|
});
|
|
505
550
|
estimatedTokens = corrected(before + mandatory, ctx);
|
|
506
|
-
let decision = decide();
|
|
551
|
+
let decision: Decision<CutPlan> = decide();
|
|
507
552
|
const { hardLine } = limits(ctx);
|
|
508
553
|
if (!operatorManual && hardLine > room && decision.kind === "fault") {
|
|
509
|
-
|
|
510
|
-
decision =
|
|
554
|
+
decision = fits(before + mandatory, hardLine, ctx) ? { kind: "none" } : decide(hardLine, hardLine);
|
|
555
|
+
decision = clampAboveRoom(decision, ctx, room, hardLine,
|
|
556
|
+
candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory,
|
|
557
|
+
candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, [], ctx.getSystemPrompt(), pi.getActiveTools(), candidate.kind === "cut"),
|
|
558
|
+
target => target <= room ? decide() : decide(target, target),
|
|
559
|
+
() => deepestDecision(ctx, operatorManual || !admittedRun, retain));
|
|
511
560
|
}
|
|
512
561
|
if (decision.kind === "cut") { return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore,
|
|
513
562
|
retain, operatorManual, event.preparation.previousSummary, { response, host, mandatory, target: decision.target, margin: room - band }) }; }
|
|
514
563
|
// 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 };
|
|
564
|
+
if (decision.kind === "none" || decision.kind === "defer") return { cancel: true };
|
|
516
565
|
recorder.stop("no-cut");
|
|
517
566
|
ctx.ui.notify(decision.message, "warning");
|
|
518
567
|
if (operatorManual) receipt("refusal", decision.message, ctx);
|
|
@@ -533,20 +582,63 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
533
582
|
pi.on("session_compact", (_event, ctx) => { if (!admittedRun) syncHistoryTools(ctx); });
|
|
534
583
|
pi.on("turn_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
535
584
|
pi.on("agent_settled", (_event, ctx) => syncHistoryTools(ctx));
|
|
585
|
+
// 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:
|
|
586
|
+
// 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)
|
|
587
|
+
// that ends normally clears it.
|
|
588
|
+
let lengthStop = false;
|
|
589
|
+
pi.on("agent_start", () => { lengthStop = false; });
|
|
590
|
+
pi.on("message_end", event => { if (event.message.role === "assistant") lengthStop = event.message.stopReason === "length"; });
|
|
591
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
592
|
+
if (!lengthStop || !active()) return;
|
|
593
|
+
lengthStop = false;
|
|
594
|
+
const message = "pi-tuck: the last reply stopped at the model's output limit and may be cut off. Ask the agent to continue.";
|
|
595
|
+
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
|
|
596
|
+
});
|
|
536
597
|
// 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
|
|
598
|
+
const previewCut = (ctx: ExtensionContext, cut: CutEntry) => {
|
|
538
599
|
const header = ctx.sessionManager.getHeader();
|
|
539
600
|
if (!header) throw new Error("Missing native session header for the prompt's cut");
|
|
540
601
|
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
541
|
-
preview.appendCompaction(cut.summary,
|
|
602
|
+
preview.appendCompaction(cut.summary, cut.firstKeptEntryId, 0, cut.details, true);
|
|
542
603
|
return preview;
|
|
543
604
|
};
|
|
605
|
+
const cutSession = (ctx: ExtensionContext, cut: CutEntry) => previewCut(ctx, { ...cut, firstKeptEntryId: keptFrom(cut, ctx) });
|
|
544
606
|
const withCut = (ctx: ExtensionContext, cut: CutEntry) => cutSession(ctx, cut).buildSessionProjection().messages;
|
|
607
|
+
// The shared final planner: preserve the compiler's prompt/protocol floor and spend nothing on optional history. Fresh text results
|
|
608
|
+
// get the smallest existing head/tail + recall-locator representation; their originals remain in the session, as for ordinary clips.
|
|
609
|
+
const deepestDecision = (ctx: ExtensionContext, newPrompt: boolean, retain: boolean): Decision<CutPlan> => {
|
|
610
|
+
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
611
|
+
if (!newPrompt) {
|
|
612
|
+
const at = saved.findLastIndex(message => message.role === "assistant" && !isDiscardedResponse(message));
|
|
613
|
+
const response = saved[at];
|
|
614
|
+
const calls = new Set(response?.role === "assistant" ? response.content.flatMap(block => block.type === "toolCall" ? [block.id] : []) : []);
|
|
615
|
+
const results = saved.slice(at + 1).filter((message): message is ToolResultMessage => message.role === "toolResult" && calls.has(message.toolCallId));
|
|
616
|
+
const entryIdOf = (id: string) => ctx.sessionManager.getBranch().findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === id)?.id;
|
|
617
|
+
for (const result of results) {
|
|
618
|
+
let low = 0, high = conversationTokens([result]) - 1;
|
|
619
|
+
if (!clipFor([result], entryIdOf, high).length) continue;
|
|
620
|
+
while (low < high) {
|
|
621
|
+
const middle = Math.floor((low + high) / 2);
|
|
622
|
+
if (clipFor([result], entryIdOf, middle).length) high = middle; else low = middle + 1;
|
|
623
|
+
}
|
|
624
|
+
for (const clip of clipFor([result], entryIdOf, low)) clips.set(clip.toolCallId, clip);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const before = measureFor(ctx)(saved);
|
|
628
|
+
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before,
|
|
629
|
+
previousSummary: saved.find(message => message.role === "compactionSummary")?.summary, newPrompt, reRenderSummary: true,
|
|
630
|
+
protectedPromptEntryId: newPrompt ? undefined : protectedPrompt(ctx), deepest: true }, ctx, 0, plannerMeasureFor(ctx), options.recallPolicy, retain);
|
|
631
|
+
if (!draft.compaction) return { kind: "fault", capacity: Boolean(draft.capacityRejected), message: draft.unavailable };
|
|
632
|
+
const messages = previewCut(ctx, { type: "compaction", ...draft.compaction }).buildSessionProjection().messages;
|
|
633
|
+
assertNativeToolRounds(messages);
|
|
634
|
+
return { kind: "cut", target: "room", plan: { draft: true, after: measureFor(ctx)(messages), mode: draft.mode,
|
|
635
|
+
compaction: draft.compaction, reserve: 0, messages } };
|
|
636
|
+
};
|
|
545
637
|
// 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
638
|
// 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
639
|
// keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
|
|
548
640
|
// 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?:
|
|
641
|
+
const preflight = (ctx: ExtensionContext, incomingPart: number, incomingMessages: Messages, observedPrompt: string, tools: readonly string[], hostSnapshot?: Messages) => {
|
|
550
642
|
promptCut = undefined;
|
|
551
643
|
promptCutAt = ctx.sessionManager.getBranch().length;
|
|
552
644
|
if (!active()) return;
|
|
@@ -558,7 +650,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
558
650
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
559
651
|
const system = requestSystemPart(observedPrompt, tools, saved, ctx.getSystemPrompt());
|
|
560
652
|
const band = decisionBand(ctx, room, system);
|
|
561
|
-
const
|
|
653
|
+
const hostInput = hostSnapshot ?? hostMessages(ctx), host = conversationTokens(hostInput), measure = measureFor(ctx), before = measure(saved);
|
|
562
654
|
const incoming = incomingPart + system, mandatory = incoming + host;
|
|
563
655
|
observeBudget(before + mandatory, ctx);
|
|
564
656
|
estimatedTokens = corrected(before + mandatory, ctx);
|
|
@@ -572,7 +664,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
572
664
|
const decide = (targetRoom: number, targetBand: number, opportunistic: boolean) => chooseCut({
|
|
573
665
|
before, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic,
|
|
574
666
|
unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
|
|
575
|
-
plan: (target): Plan<
|
|
667
|
+
plan: (target): Plan<CutPlan> => {
|
|
576
668
|
const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
|
|
577
669
|
// 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
670
|
// there buys nothing the request needed and costs history the next recall would have found.
|
|
@@ -580,14 +672,19 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
580
672
|
if (!draft.compaction) return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
581
673
|
const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
|
|
582
674
|
assertNativeToolRounds(messages);
|
|
583
|
-
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
675
|
+
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve, messages };
|
|
584
676
|
},
|
|
585
677
|
});
|
|
586
|
-
let decision = gate.kind === "plan" ? decide(room, band, gate.opportunistic) : { kind: "defer"
|
|
678
|
+
let decision: Decision<CutPlan> = gate.kind === "plan" ? decide(room, band, gate.opportunistic) : { kind: "defer" };
|
|
587
679
|
const { hardLine } = limits(ctx);
|
|
588
680
|
if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
|
|
589
|
-
|
|
590
|
-
|
|
681
|
+
decision = fits(before + mandatory, hardLine, ctx) ? { kind: "none" } :
|
|
682
|
+
fits(mandatory, hardLine, ctx) ? decide(hardLine, hardLine, false) : { kind: "defer" };
|
|
683
|
+
decision = clampAboveRoom(decision, ctx, room, hardLine,
|
|
684
|
+
candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory,
|
|
685
|
+
candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, incomingMessages, observedPrompt, tools, candidate.kind === "cut"),
|
|
686
|
+
target => decide(target, target <= room ? band : target, false),
|
|
687
|
+
() => deepestDecision(ctx, true, false));
|
|
591
688
|
}
|
|
592
689
|
if (decision.kind === "defer") return;
|
|
593
690
|
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
@@ -606,7 +703,8 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
606
703
|
try {
|
|
607
704
|
syncHistoryTools(ctx);
|
|
608
705
|
if (prompted) access(); // decided on input; a failure latched there still stops the run
|
|
609
|
-
else await preflight(ctx, incomingEventTokens(event),
|
|
706
|
+
else await preflight(ctx, incomingEventTokens(event), [{ role: "user", timestamp: 0,
|
|
707
|
+
content: event.messageType === "custom" ? event.message.content : [{ type: "text", text: event.prompt }, ...(event.images ?? [])] }], event.systemPrompt,
|
|
610
708
|
historyToolNames(pi, event.systemPromptOptions.selectedTools, historyToolsEnabled(ctx)));
|
|
611
709
|
// Pi declares tool changes before turn_start, then snapshots executable tools in prepareRequest. Introduce our tools in
|
|
612
710
|
// the request projection: recording an activation message here would shift every later session-global recall index.
|
|
@@ -629,10 +727,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
629
727
|
const incoming = incomingTokens(typeof message.content === "string" ? message.content : message.content.flatMap(b => b.type === "text" ? [b.text] : []).join("\n"),
|
|
630
728
|
typeof message.content === "string" ? [] : message.content.filter((b): b is ImageContent => b.type === "image"));
|
|
631
729
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
632
|
-
const
|
|
730
|
+
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
633
731
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
634
732
|
if (fits(conversationTokens(saved) + system + incoming + host, limits(ctx).room, ctx)) return;
|
|
635
|
-
preflight(ctx, incoming, ctx.getSystemPrompt(), pi.getActiveTools(),
|
|
733
|
+
preflight(ctx, incoming, [message], ctx.getSystemPrompt(), pi.getActiveTools(), hostInput);
|
|
636
734
|
syncHistoryTools(ctx);
|
|
637
735
|
} catch (error) { fail(error, ctx); }
|
|
638
736
|
});
|
|
@@ -654,7 +752,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
654
752
|
// request while tools still run, so it can be counted early: conservative, and nothing else would cut at that later boundary.
|
|
655
753
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
656
754
|
const band = decisionBand(ctx, room, system);
|
|
657
|
-
const
|
|
755
|
+
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
658
756
|
const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages as Messages) : 0;
|
|
659
757
|
const mandatory = system + host + queued;
|
|
660
758
|
// Without usable provider usage, Pi's own threshold uses its unwrapped message estimate as the compiler's density input.
|
|
@@ -680,17 +778,18 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
680
778
|
let floorKept: number | undefined;
|
|
681
779
|
let wholePromptFloor: number | undefined;
|
|
682
780
|
const protectedId = reason === "tool-loop" ? protectedPrompt(ctx) : undefined;
|
|
683
|
-
const decide = (targetRoom = room, targetBand = band) => {
|
|
781
|
+
const decide = (targetRoom = room, targetBand = band, required = false) => {
|
|
684
782
|
previews = [];
|
|
685
783
|
floorKept = undefined;
|
|
686
784
|
wholePromptFloor = undefined;
|
|
687
785
|
const before = measure(saved);
|
|
688
|
-
const gate =
|
|
786
|
+
const gate = required && fits(mandatory, targetRoom, ctx) ? { kind: "plan" as const, opportunistic: false } :
|
|
787
|
+
gateCut({ total: before + mandatory, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
689
788
|
if (gate.kind !== "plan") return gate;
|
|
690
789
|
return chooseCut({
|
|
691
790
|
opportunistic: gate.opportunistic, mandatory, before, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
|
|
692
791
|
unreduced: "Tool-loop preflight compaction did not reduce context within the estimated request budget",
|
|
693
|
-
plan: (target): Plan<
|
|
792
|
+
plan: (target): Plan<CutPlan> => {
|
|
694
793
|
const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
|
|
695
794
|
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: densityTokens ?? before, previousSummary, protectedPromptEntryId: protectedId }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
|
|
696
795
|
if (!draft.compaction) {
|
|
@@ -712,17 +811,17 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
712
811
|
const messages = preview.buildSessionProjection().messages;
|
|
713
812
|
assertNativeToolRounds(messages);
|
|
714
813
|
previews.push(messages);
|
|
715
|
-
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
814
|
+
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve, messages };
|
|
716
815
|
},
|
|
717
816
|
});
|
|
718
817
|
};
|
|
719
|
-
let decision = decide();
|
|
818
|
+
let decision: Decision<CutPlan> = decide();
|
|
720
819
|
// 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
820
|
// 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
821
|
// clipBudgets states the budgets tried and why they are counted in the estimate's units; `kept` is measured from the previews this decision
|
|
723
822
|
// 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
823
|
// refusal stands.
|
|
725
|
-
const tryClips = (clipRoom: number, targetBand: number) => {
|
|
824
|
+
const tryClips = (clipRoom: number, targetBand: number, required = false) => {
|
|
726
825
|
if (decision.kind !== "fault" || !event.toolResults) return;
|
|
727
826
|
const branch = ctx.sessionManager.getBranch();
|
|
728
827
|
const entryIdOf = (toolCallId: string) => branch.findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId)?.id;
|
|
@@ -733,7 +832,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
733
832
|
const round = clipFor(event.toolResults, entryIdOf, budget);
|
|
734
833
|
if (!round.length) continue;
|
|
735
834
|
for (const clip of round) clips.set(clip.toolCallId, clip);
|
|
736
|
-
const clippedDecision = decide(clipRoom, targetBand);
|
|
835
|
+
const clippedDecision = decide(clipRoom, targetBand, required);
|
|
737
836
|
if (clippedDecision.kind === "fault") for (const clip of round) clips.delete(clip.toolCallId);
|
|
738
837
|
else { decision = clippedDecision; break; }
|
|
739
838
|
}
|
|
@@ -744,9 +843,17 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
744
843
|
estimatedTokens = corrected(before + mandatory, ctx);
|
|
745
844
|
const { hardLine } = limits(ctx);
|
|
746
845
|
if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
|
|
747
|
-
if (fits(before + mandatory, hardLine, ctx))
|
|
748
|
-
decision = decide(hardLine, hardLine);
|
|
749
|
-
|
|
846
|
+
if (fits(before + mandatory, hardLine, ctx)) decision = { kind: "none" };
|
|
847
|
+
else { decision = decide(hardLine, hardLine); tryClips(hardLine, hardLine); }
|
|
848
|
+
decision = clampAboveRoom(decision, ctx, room, hardLine,
|
|
849
|
+
candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory,
|
|
850
|
+
candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, event.context.pendingMessages as Messages, ctx.getSystemPrompt(), pi.getActiveTools(), candidate.kind === "cut"),
|
|
851
|
+
target => {
|
|
852
|
+
const targetBand = target <= room ? band : target;
|
|
853
|
+
decision = decide(target, targetBand, target > room);
|
|
854
|
+
tryClips(target, targetBand, target > room);
|
|
855
|
+
return decision;
|
|
856
|
+
}, () => deepestDecision(ctx, false, true));
|
|
750
857
|
}
|
|
751
858
|
if (decision.kind === "fault" && reason === "tool-loop") {
|
|
752
859
|
if (wholePromptFloor !== undefined && !fits(wholePromptFloor, hardLine, ctx)) throw promptRefusal();
|
|
@@ -774,9 +881,60 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
774
881
|
setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
|
|
775
882
|
return { entries: [...event.entries as CutEntry[], saved, ...(more?.entries ?? [])] };
|
|
776
883
|
};
|
|
884
|
+
const retryThinking = (event: TurnEndEvent, ctx: ExtensionContext) => {
|
|
885
|
+
// Pi's supported boundary drafts commit before finishTurn honors `continue`. Omitting the selected failed response also prevents
|
|
886
|
+
// Pi's later native length recovery from retrying it independently. The stored message and its output usage stay on the branch.
|
|
887
|
+
const entries: SessionBoundaryDraft[] = [...(atBoundary(event, ctx, "run-end", false)?.entries ?? event.entries),
|
|
888
|
+
{ type: "context_edit", targetId: event.messageEntryId, replacement: null }];
|
|
889
|
+
if (thinkingRetryPending) return { entries, continue: false };
|
|
890
|
+
const header = ctx.sessionManager.getHeader();
|
|
891
|
+
if (!header) throw new Error("Missing native session header for the thinking-only retry");
|
|
892
|
+
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
893
|
+
for (const entry of entries) {
|
|
894
|
+
if (entry.type === "context_edit") preview.appendContextEdit(entry.targetId, entry.replacement);
|
|
895
|
+
else if (entry.type === "compaction") preview.appendCompaction(entry.summary, entry.firstKeptEntryId, 0, entry.details, true, entry.usage);
|
|
896
|
+
else if (entry.type === "custom") preview.appendCustomEntry(entry.customType, entry.data);
|
|
897
|
+
else preview.appendCustomMessageEntry(entry.customType, entry.content, entry.display, entry.details);
|
|
898
|
+
}
|
|
899
|
+
const view: ExtensionContext = Object.create(ctx, { sessionManager: { value: preview } });
|
|
900
|
+
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
901
|
+
// 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
|
|
902
|
+
// as large as the deepest cut's, the turn is retried uncut, and no history is given up for nothing.
|
|
903
|
+
const asIs = projectedRequest(preview.buildSessionProjection().messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), false);
|
|
904
|
+
const asIsCheck = requestClamp(asIs, view);
|
|
905
|
+
const previousClips = new Map(clips);
|
|
906
|
+
const decision = deepestDecision(view, false, true);
|
|
907
|
+
if (decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
|
|
908
|
+
requestClamp(projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true), view).cap! <= asIsCheck.cap) {
|
|
909
|
+
clips.clear(); for (const [id, clip] of previousClips) clips.set(id, clip);
|
|
910
|
+
const { room, hardLine } = limits(view);
|
|
911
|
+
thinkingRetryPending = true;
|
|
912
|
+
return { entries: [...entries, { type: "custom" as const, customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
|
|
913
|
+
stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: false,
|
|
914
|
+
piEstimateTokens: asIsCheck.estimate, piCap: asIsCheck.cap, room, hardLine, buildId: BUILD_ID } }], continue: true };
|
|
915
|
+
}
|
|
916
|
+
if (decision.kind !== "cut") throw new CapacityError(clampMessage);
|
|
917
|
+
const messages = projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true);
|
|
918
|
+
if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt)) throw promptRefusal();
|
|
919
|
+
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
|
|
920
|
+
const tokens = system + conversationTokens(messages), check = requestClamp(messages, view);
|
|
921
|
+
const { response, room, hardLine } = limits(view);
|
|
922
|
+
if (!fits(tokens, hardLine, view) || check.cap === undefined || !check.leavesFloor) throw new CapacityError(clampMessage);
|
|
923
|
+
const compaction = captureDraft(decision.plan.compaction, view, decision.plan.reserve, "length-retry", tokens, true, false, undefined,
|
|
924
|
+
{ response, host, mandatory: system + host + conversationTokens(event.context.pendingMessages), target: decision.target, margin: room - decisionBand(view, room, system) });
|
|
925
|
+
thinkingRetryPending = true;
|
|
926
|
+
pendingBoundaryCommit = true;
|
|
927
|
+
return { entries: [...entries, { type: "compaction" as const, summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId || null, details: compaction.details },
|
|
928
|
+
{ type: "custom" as const, customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
|
|
929
|
+
stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: true,
|
|
930
|
+
estimatedTokens: corrected(tokens, view), piEstimateTokens: check.estimate, piCap: check.cap, room, hardLine, buildId: BUILD_ID } }], continue: true };
|
|
931
|
+
};
|
|
777
932
|
pi.on("turn_end", (event, ctx) => {
|
|
778
933
|
try {
|
|
779
934
|
if (!active() || !admittedRun) return;
|
|
935
|
+
if (thinkingOnlyLength(event.message) && !failure && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
|
|
936
|
+
return retryThinking(event, ctx);
|
|
937
|
+
thinkingRetryPending = false;
|
|
780
938
|
const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
|
|
781
939
|
settingsFor().getCompactionSettings(ctx.model).enabled;
|
|
782
940
|
if (!promptCut && !decide) return;
|
|
@@ -860,16 +1018,15 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
860
1018
|
const tokens = system + conversationTokens(messages);
|
|
861
1019
|
observeBudget(tokens, ctx);
|
|
862
1020
|
estimatedTokens = corrected(tokens, ctx);
|
|
863
|
-
const { room, hardLine
|
|
1021
|
+
const { room, hardLine } = limits(ctx);
|
|
864
1022
|
if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
|
|
865
1023
|
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
1024
|
if (!fits(tokens, hardLine, ctx))
|
|
867
1025
|
throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
|
|
868
1026
|
// Pi's request estimator and its compaction estimator need not agree. The hard line is
|
|
869
1027
|
// 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");
|
|
1028
|
+
if (!options.requestReserve && estimatedTokens > room && piClamp && !requestClamp(messages, ctx).leavesFloor)
|
|
1029
|
+
throw new CapacityError(clampMessage);
|
|
873
1030
|
if (estimatedTokens > room) receipt("above-room", "Request exceeds the room and fits the hard line", ctx);
|
|
874
1031
|
if (recorder.enabled && !recorder.waitingForCut) recorder.measureInline(() => {
|
|
875
1032
|
recorder.request();
|
package/src/request-capacity.ts
CHANGED
|
@@ -1,16 +1,49 @@
|
|
|
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
|
+
type SimpleOptions = typeof import('@earendil-works/pi-ai/api/simple-options');
|
|
7
|
+
type Estimate = typeof import('@earendil-works/pi-ai/utils/estimate');
|
|
8
|
+
export type PiRequestMath = { clampMaxTokensToContext: SimpleOptions['clampMaxTokensToContext']; estimateContextTokens: Estimate['estimateContextTokens'] };
|
|
9
|
+
|
|
10
|
+
/** Pi's own request arithmetic: its reply clamp and the estimator behind it, as the running Pi has them.
|
|
11
|
+
* An installed extension cannot import these subpaths by name: Pi's extension loader aliases only pi-ai's root, compat, models, oauth and
|
|
12
|
+
* providers/all, and an installed package has no node_modules of its own. So resolve them first by name (tests, the eval harness, a dev
|
|
13
|
+
* checkout), then from the running Pi's own install, beside its executable. If neither works (an SDK host), there is none, and callers
|
|
14
|
+
* keep their fallbacks. */
|
|
15
|
+
export async function loadPiRequestMath(): Promise<PiRequestMath | undefined> {
|
|
16
|
+
try {
|
|
17
|
+
const [simple, estimate] = await Promise.all([import('@earendil-works/pi-ai/api/simple-options'), import('@earendil-works/pi-ai/utils/estimate')]);
|
|
18
|
+
return { clampMaxTokensToContext: simple.clampMaxTokensToContext, estimateContextTokens: estimate.estimateContextTokens };
|
|
19
|
+
} catch { /* not resolvable by name: an installed extension */ }
|
|
20
|
+
try {
|
|
21
|
+
// pi-ai's exports map these subpaths for `import` only (./api/* -> ./dist/api/*.js), so find the package beside Pi's executable and
|
|
22
|
+
// import the files that map names.
|
|
23
|
+
const [{ existsSync, realpathSync }, { dirname, join }, { pathToFileURL }] = await Promise.all([import('node:fs'), import('node:path'), import('node:url')]);
|
|
24
|
+
const entry = process.argv[1];
|
|
25
|
+
if (!entry) return undefined;
|
|
26
|
+
let dir = dirname(realpathSync(entry)), root: string | undefined;
|
|
27
|
+
for (let up = 0; up < 12 && !root; up++, dir = dirname(dir)) {
|
|
28
|
+
const candidate = join(dir, 'node_modules', '@earendil-works', 'pi-ai');
|
|
29
|
+
if (existsSync(join(candidate, 'dist', 'api', 'simple-options.js'))) root = candidate;
|
|
30
|
+
if (dirname(dir) === dir) break;
|
|
31
|
+
}
|
|
32
|
+
if (!root) return undefined;
|
|
33
|
+
const [simple, estimate] = await Promise.all([
|
|
34
|
+
import(pathToFileURL(join(root, 'dist', 'api', 'simple-options.js')).href) as Promise<SimpleOptions>,
|
|
35
|
+
import(pathToFileURL(join(root, 'dist', 'utils', 'estimate.js')).href) as Promise<Estimate>]);
|
|
36
|
+
if (typeof simple.clampMaxTokensToContext !== 'function' || typeof estimate.estimateContextTokens !== 'function') return undefined;
|
|
37
|
+
return { clampMaxTokensToContext: simple.clampMaxTokensToContext, estimateContextTokens: estimate.estimateContextTokens };
|
|
38
|
+
} catch { return undefined; }
|
|
39
|
+
}
|
|
40
|
+
|
|
6
41
|
/** Read Pi's private safety allowance through its public clamp, rather than copying it.
|
|
7
|
-
*
|
|
42
|
+
* Without Pi's request arithmetic, fall back to Pi 1.2–1.5's documented 4,096-token allowance.
|
|
8
43
|
*/
|
|
9
|
-
export async function piContextSafetyMargin(): Promise<number> {
|
|
44
|
+
export async function piContextSafetyMargin(math?: PiRequestMath): Promise<number> {
|
|
10
45
|
try {
|
|
11
|
-
const
|
|
12
|
-
import('@earendil-works/pi-ai/api/simple-options'), import('@earendil-works/pi-ai/utils/estimate'),
|
|
13
|
-
]);
|
|
46
|
+
const { clampMaxTokensToContext, estimateContextTokens } = math ?? await loadPiRequestMath() ?? (() => { throw new Error('No Pi request math'); })();
|
|
14
47
|
const window = 1_000_000, context = normalizeContext({ messages: [] });
|
|
15
48
|
const margin = window - estimateContextTokens(context).tokens - clampMaxTokensToContext({ contextWindow: window, maxTokens: window } as never, context, window);
|
|
16
49
|
if (!Number.isSafeInteger(margin) || margin < 0 || margin >= window) throw new Error('Invalid Pi context margin');
|
|
@@ -21,17 +54,28 @@ export async function piContextSafetyMargin(): Promise<number> {
|
|
|
21
54
|
/** 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
55
|
export const REPLY_FLOOR_MIN = 3_584;
|
|
23
56
|
|
|
57
|
+
/** A spent thinking budget that produced neither a visible answer nor work. Empty/text/tool replies keep the existing length recovery. */
|
|
58
|
+
export const thinkingOnlyLength = (message: AgentMessage): boolean => message.role === 'assistant' && message.stopReason === 'length' &&
|
|
59
|
+
message.content.length > 0 && message.content.every(block => block.type === 'thinking');
|
|
60
|
+
|
|
61
|
+
/** Thinking-only exhaustion uses the observed largest reply, not the full reserve. Every other failed length/overflow keeps its override. */
|
|
62
|
+
export const needsFullReplyReserve = (message: AgentMessage): boolean => message.role === 'assistant' &&
|
|
63
|
+
(message.stopReason === 'length' && !thinkingOnlyLength(message) || message.stopReason === 'error' && isContextOverflow(message));
|
|
64
|
+
|
|
24
65
|
/** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
|
|
25
66
|
export function branchReplyFloor(branch: readonly SessionEntry[], reserve: number): number {
|
|
26
|
-
let largest = REPLY_FLOOR_MIN;
|
|
67
|
+
let largest = REPLY_FLOOR_MIN, thinkingStop = false;
|
|
27
68
|
for (const entry of branch) {
|
|
28
69
|
if (entry.type !== 'message' || entry.message.role !== 'assistant') continue;
|
|
29
70
|
const message = entry.message;
|
|
30
|
-
if (
|
|
71
|
+
if (needsFullReplyReserve(message)) return reserve;
|
|
72
|
+
thinkingStop ||= thinkingOnlyLength(message);
|
|
31
73
|
const output = message.usage?.output;
|
|
32
74
|
if (typeof output === 'number' && Number.isFinite(output) && output > largest) largest = output;
|
|
33
75
|
}
|
|
34
|
-
|
|
76
|
+
// Include the exhausted thinking reply itself, even across later cuts/restarts. Its observed output must fit again;
|
|
77
|
+
// requestReplyFloor applies the model cap. Ordinary branches retain their existing reserve cap.
|
|
78
|
+
return thinkingStop ? Math.ceil(largest) : Math.min(reserve, Math.ceil(largest));
|
|
35
79
|
}
|
|
36
80
|
|
|
37
81
|
/** Admission never promises more output than a model with a positive reply cap can produce. */
|