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/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 Messages, type Plan, type Target, type ToolShape } from "./budget.js";
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 piMargin = options.requestReserve ? 0 : await piContextSafetyMargin();
137
- const piClamp = options.requestReserve ? undefined : await import('@earendil-works/pi-ai/api/simple-options')
138
- .then(module => module.clampMaxTokensToContext).catch(() => undefined);
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. Other errors, such as a 503,
289
- // keep native's room. Read the whole branch, including history before compactions, each time: no flag to persist or reset on resume,
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.role === "assistant" &&
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 hostTokens = (ctx: ExtensionContext) => options.hostMessages ? conversationTokens(options.hostMessages(ctx)) : 0;
331
- const toolShapes = (names: Iterable<string>): ToolShape[] => {
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), ctx.getSystemPrompt(), pi.getActiveTools()); syncHistoryTools(ctx); }
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 host = hostTokens(ctx);
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<{ compaction: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>; reserve: number }> => {
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
- if (fits(before + mandatory, hardLine, ctx)) return { cancel: true };
510
- decision = decide(hardLine, hardLine);
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 cutSession = (ctx: ExtensionContext, cut: CutEntry) => {
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, keptFrom(cut, ctx), 0, cut.details, true);
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?: number) => {
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 host = hostSnapshot ?? hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
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<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
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" as const };
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
- if (fits(before + mandatory, hardLine, ctx)) return;
590
- decision = fits(mandatory, hardLine, ctx) ? decide(hardLine, hardLine, false) : { kind: "defer" };
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), event.systemPrompt,
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 host = hostTokens(ctx);
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(), host);
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 host = hostTokens(ctx);
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 = gateCut({ total: before + mandatory, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx) });
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<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
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)) return;
748
- decision = decide(hardLine, hardLine);
749
- tryClips(hardLine, hardLine);
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, replyFloor } = limits(ctx);
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
- piClamp(ctx.model, normalizeContext({ messages: convertToLlm(messages) }), ctx.model.maxTokens) < replyFloor)
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();
@@ -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
- * Older/unavailable subpaths fall back to Pi 1.2–1.5's documented 4,096-token allowance.
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 [{ clampMaxTokensToContext }, { estimateContextTokens }] = await Promise.all([
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 (message.stopReason === 'length' || message.stopReason === 'error' && isContextOverflow(message)) return reserve;
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
- return Math.min(reserve, Math.ceil(largest));
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. */