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/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,55 @@
|
|
|
3
3
|
All notable changes to pi-tuck. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
4
4
|
[Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [1.0.26] - 2026-11-10
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **1.0.25 failed to load in an installed Pi** ("Cannot find module '@earendil-works/pi-ai/utils/estimate'"). Pi's extension loader resolves
|
|
11
|
+
only a few pi-ai entry points for an installed package, and 1.0.25 imported another one by name.
|
|
12
|
+
- **Since 1.0.23, the check of Pi's own reply cap never ran in an installed Pi**, for the same reason. It loaded only in tests and the eval
|
|
13
|
+
harness, where pi-ai resolves by name; the installed package silently fell back without it. The 4,096-token margin in the hard line held,
|
|
14
|
+
because it fell back to the same value.
|
|
15
|
+
- pi-tuck now loads Pi's reply clamp and estimator from the running Pi's own install. If it can't find them (a host embedding Pi), it
|
|
16
|
+
keeps the fallback as before.
|
|
17
|
+
|
|
18
|
+
### Release gates
|
|
19
|
+
|
|
20
|
+
- **New gate:** the packed tarball was loaded in the real installed Pi before publishing. It loaded, and Pi's clamp resolved from Pi's own
|
|
21
|
+
install.
|
|
22
|
+
- Requests are unchanged from 1.0.25 wherever pi-ai resolves by name, which covers replay, stress and tests. Tests and CI pass.
|
|
23
|
+
|
|
24
|
+
## [1.0.25] - 2026-11-10
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **A request above the room could be admitted and then refused, leaving the session stuck where native Pi would have sent.** pi-tuck checked
|
|
29
|
+
its line in its own corrected token count. Pi's reply cap uses Pi's own estimate, which is higher when the provider counts fewer tokens
|
|
30
|
+
than pi-tuck's raw measure.
|
|
31
|
+
- Now each point that sends above the room checks Pi's own cap on the request it would send.
|
|
32
|
+
- When that leaves less than the reply floor, it cuts deeper: up to three re-plans, then the deepest cut that still carries the whole
|
|
33
|
+
prompt. It refuses only if that leaves less than the floor too.
|
|
34
|
+
- In the stress test, 7 refusals now answer, 3 of which had stranded the session.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **One retry after a reply that stops for length with thinking only.** pi-tuck retries the turn once, leaving the stopped reply out of the
|
|
39
|
+
request. It uses the deepest cut that still carries the whole prompt when that gives the reply more room; otherwise it retries uncut.
|
|
40
|
+
- After such a stop, the reply floor is the largest reply on the branch, the stopped one included, instead of the whole reserve. The full
|
|
41
|
+
reserve would refuse every request when the setup alone is above the room.
|
|
42
|
+
- A retried reply that stops again ends the run.
|
|
43
|
+
- `/tuck` counts retries.
|
|
44
|
+
- **A notice when a run ends on a reply cut at the output limit:** "the last reply stopped at the model's output limit and may be cut off".
|
|
45
|
+
It goes to stderr when there's no UI and never enters the model's context.
|
|
46
|
+
- **Stress runs strict in CI:** any finding fails it. A refusal is exempt only when an independent check allows it. One such check is Pi's
|
|
47
|
+
own reply cap on the smallest request that answers the prompt.
|
|
48
|
+
|
|
49
|
+
### Release gates
|
|
50
|
+
|
|
51
|
+
- **Shipped without a live gate, because its replay matches 1.0.24 exactly:** 13,946 requests on 29 recordings, 0 different, 0 lost and
|
|
52
|
+
0 dead tasks, clips 17 → 17.
|
|
53
|
+
- **Stress, 310 cells, strict:** 0 findings (1.0.24: 25 lines, 14 of them this defect). Tests (731) and CI.
|
|
54
|
+
|
|
6
55
|
## [1.0.24] - 2026-11-10
|
|
7
56
|
|
|
8
57
|
### Fixed
|
package/README.md
CHANGED
|
@@ -64,8 +64,8 @@ Then start a new Pi session. That is all. pi-tuck takes over Pi's compaction, bo
|
|
|
64
64
|
|
|
65
65
|
- Needs Pi 1.2.0 or later (tested on 1.2.0 and 1.4.0), on macOS or Linux. On an older Pi, pi-tuck stays off, Pi's own compaction runs, and pi-tuck says so once. Full features on macOS with Apple silicon; elsewhere, compaction and word recall.
|
|
66
66
|
- On macOS with Apple silicon, the first session also downloads a local search model in the background (about 390 MB, once), and says once when it is set up. Until it is ready, search works by keyword. See [Semantic search](#semantic-search).
|
|
67
|
-
- Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.
|
|
68
|
-
- To pin a version, add it: `pi install npm:pi-tuck@1.0.
|
|
67
|
+
- Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.24`.
|
|
68
|
+
- To pin a version, add it: `pi install npm:pi-tuck@1.0.24`.
|
|
69
69
|
|
|
70
70
|
## Uninstall
|
|
71
71
|
|
|
@@ -133,6 +133,7 @@ Known limits today:
|
|
|
133
133
|
- pi-tuck refused requests that fit the window, and could strand a session (1.0.0 to 1.0.21; fixed in 1.0.22). It refused at the room, the window less the full reply reserve, where native Pi still sends. With Pi's defaults and a large setup, a session could be refused at every prompt. The final refusal now sits at the window less a reply floor ([`CHANGELOG.md`](CHANGELOG.md) 1.0.22).
|
|
134
134
|
- Near the 1.0.22 refusal line, replies were cut to a few hundred tokens (1.0.22; fixed in 1.0.23). 1.0.22 left only its reply floor and did not count Pi's 4,096-token margin on the reply cap ([`CHANGELOG.md`](CHANGELOG.md) 1.0.23).
|
|
135
135
|
- Cost of the 1.0.22 change: between the room and the window, the reply reserve no longer cushions a wrong count. At a cold start an undercount can reach the provider. A provider that errors is caught and learned from; a server that truncates silently truncates, as it does for native Pi. With a setup above the room, cuts are forced and frequent, each freeing little, which beats a stranded session ([`CHANGELOG.md`](CHANGELOG.md) 1.0.22).
|
|
136
|
+
- A prompt's cut could reach a run it was not planned for (fixed in 1.0.24). pi-tuck plans a prompt's cut when the prompt arrives. When another extension handled that prompt, no run started, and the plan waited for the next run. A run started by a custom message carried a cut sized for the prompt that never ran, deeper than needed. After a move in the session tree (`/tree`), the plan was applied on the new branch, where its kept entry was not present, so the request lost the custom message that started the run, even on a branch that needed no cut ([`CHANGELOG.md`](CHANGELOG.md) 1.0.24).
|
|
136
137
|
- Four `tuck_search` faults, fixed in 1.0.23: it said semantic recall was still indexing when the history was fully indexed; in the turn after a prompt's cut it ranked nothing ("nothing is out of view"); a keyword-search timeout on a very long history failed the whole search, where now the semantic results still answer; and the timeout message named a pattern instead of the history's size ([`CHANGELOG.md`](CHANGELOG.md) 1.0.23).
|
|
137
138
|
- pi-tuck cut earlier than native Pi when a model's reply cap is above Pi's reserve (1.0.0 to 1.0.19; fixed in 1.0.20). It reserved the larger of Pi's `compaction.reserveTokens` and the model's `maxTokens`: with `maxTokens` 32,768 and a reserve of 16,384 on an 81,920 window, its room fell to 49,152 tokens, where native Pi compacts at 65,536 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.20).
|
|
138
139
|
- A summary could drop the task a session was working on (1.0.0 to 1.0.17; fixed in 1.0.18). Until 1.0.15 a summary quoted only the session's first user turn, up to 12,000 characters, and the newest one, so in a long session the current task, an older turn followed by short steps, was clipped or dropped while a finished first task stayed whole, and agents acted on a days-old task. 1.0.16 added more quotes. From 1.0.13 to 1.0.17, separate rules decided what a summary quoted and what the kept tail held, and the prompt that set the current task, or the first prompt, could lose its opening after a cut: the stress test found it 40 times in 32 cells on 1.0.16 and 1.0.17. Since 1.0.18 one ranked list fills a cut's room, and the stress test finds no lost opening in 287 cells. Short sessions, and the SWE-bench chains behind our benchmarks, keep each task's prompt in the kept tail and never hit it. Upgrade to 1.0.18 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.18).
|
package/dist/src/build-id.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const BUILD_ID = "
|
|
1
|
+
export declare const BUILD_ID = "e95edde95c614d01e1cbf6489134676fac0c1bbf6aa57ef639ffac1e7e105041";
|
package/dist/src/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "e95edde95c614d01e1cbf6489134676fac0c1bbf6aa57ef639ffac1e7e105041";
|
package/dist/src/compaction.d.ts
CHANGED
|
@@ -51,7 +51,8 @@ export interface CompactionOptions {
|
|
|
51
51
|
cutAt?: number;
|
|
52
52
|
/** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
|
|
53
53
|
* Standalone cuts against Pi's reserveTokens, then admits up to the window less Pi's safety margin and max(3,584, largest branch output), capped at that reserve.
|
|
54
|
-
* After a length stop or context overflow it holds the full reserve, also raised to the catalogue maximum.
|
|
54
|
+
* After a length stop with text/tool output or context overflow it holds the full reserve, also raised to the catalogue maximum.
|
|
55
|
+
* Thinking-only exhaustion keeps Pi's reserve and requires the branch's largest observed reply instead.
|
|
55
56
|
* Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
|
|
56
57
|
requestReserve?: (ctx: ExtensionContext) => {
|
|
57
58
|
response: number;
|
package/dist/src/compaction.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
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 } from "./semantic-recall.js";
|
|
5
5
|
import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry } 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 } from "./clip.js";
|
|
12
12
|
import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt } from "./budget.js";
|
|
@@ -104,9 +104,9 @@ export function seedWindow(branch, key) {
|
|
|
104
104
|
return initialWindow(key);
|
|
105
105
|
}
|
|
106
106
|
export async function installCompaction(pi, options = {}) {
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
107
|
+
const piMath = await loadPiRequestMath();
|
|
108
|
+
const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin(piMath);
|
|
109
|
+
const piClamp = piMath?.clampMaxTokensToContext;
|
|
110
110
|
const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
|
|
111
111
|
if (!validCutAt(cutAt))
|
|
112
112
|
throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
|
|
@@ -224,6 +224,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
224
224
|
// Pi's native start/settled events bracket the run and its post-turn cuts.
|
|
225
225
|
let admittedRun = false;
|
|
226
226
|
let runPrompt;
|
|
227
|
+
let thinkingRetryPending = false;
|
|
227
228
|
const protectedPrompt = (ctx) => runPrompt ? ctx.sessionManager.getBranch().findLast(entry => entry.type === "message" && entry.message.role === "user" && textOf(entry.message.content).includes(runPrompt))?.id : undefined;
|
|
228
229
|
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");
|
|
229
230
|
let estimatedTokens = 0;
|
|
@@ -301,21 +302,21 @@ export async function installCompaction(pi, options = {}) {
|
|
|
301
302
|
if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio))
|
|
302
303
|
learn(ratio);
|
|
303
304
|
});
|
|
304
|
-
pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
|
|
305
|
-
pi.on("agent_settled", () => { admittedRun = false; runPrompt = undefined; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
|
|
305
|
+
pi.on("agent_start", () => { admittedRun = true; thinkingRetryPending = false; pending = undefined; phase = "idle"; });
|
|
306
|
+
pi.on("agent_settled", () => { admittedRun = false; runPrompt = undefined; thinkingRetryPending = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
|
|
306
307
|
const active = () => options.active?.() ?? true;
|
|
307
308
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
308
309
|
// The one reserve decision; every consumer takes its room from limits. Standalone starts at Pi's native cut point. Once a reply did
|
|
309
|
-
// not fit (a length stop, or a refusal pi-ai classes as a context overflow), hold the catalogue maximum too.
|
|
310
|
-
//
|
|
310
|
+
// 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
|
|
311
|
+
// length stop uses branchReplyFloor's observed output instead; reserving the catalogue maximum would strand setups above the room.
|
|
312
|
+
// 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,
|
|
311
313
|
// fork, /tree or a new session.
|
|
312
314
|
const requestReserve = (ctx) => {
|
|
313
315
|
const host = options.requestReserve?.(ctx);
|
|
314
316
|
const budget = host ?? { response: settingsFor().getCompactionSettings(ctx.model).reserveTokens };
|
|
315
317
|
if (!Number.isSafeInteger(budget.response) || budget.response < 0)
|
|
316
318
|
throw new Error("Invalid host request reserve");
|
|
317
|
-
const floor = host ? host.responseExact !== true : ctx.sessionManager.getBranch().some(entry => entry.type === "message" && entry.message
|
|
318
|
-
(entry.message.stopReason === "length" || (entry.message.stopReason === "error" && isContextOverflow(entry.message))));
|
|
319
|
+
const floor = host ? host.responseExact !== true : ctx.sessionManager.getBranch().some(entry => entry.type === "message" && needsFullReplyReserve(entry.message));
|
|
319
320
|
return { response: floor ? Math.max(budget.response, ctx.model?.maxTokens ?? 0) : budget.response };
|
|
320
321
|
};
|
|
321
322
|
const inputRoom = (response, ctx) => {
|
|
@@ -345,7 +346,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
345
346
|
// The planner counts with the same function, except that it does not fail on a summary in another format: a cut can replace one
|
|
346
347
|
// (an operator's /compact repairs a session whose saved summary is foreign). Identical to the strict one for this compactor's summaries.
|
|
347
348
|
const plannerMeasureFor = (ctx) => Object.assign((messages) => measurableConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy), { messageTokens: (message) => conversationTokens(applyClips([message], clips)) });
|
|
348
|
-
const
|
|
349
|
+
const hostMessages = (ctx) => options.hostMessages?.(ctx) ?? [];
|
|
349
350
|
const toolShapes = (names) => {
|
|
350
351
|
const selected = new Set(names);
|
|
351
352
|
return pi.getAllTools().filter(tool => selected.has(tool.name)).map(tool => ({ name: tool.name, description: tool.description, parameters: tool.parameters }));
|
|
@@ -358,6 +359,53 @@ export async function installCompaction(pi, options = {}) {
|
|
|
358
359
|
const future = saved.some(message => message.role === "system") ? historyToolDeclarations(saved, shapes, true) : saved;
|
|
359
360
|
return systemPart(observedPrompt, shapes, future, guardPrompt);
|
|
360
361
|
};
|
|
362
|
+
const clampMessage = "Pi's request clamp leaves less than the reply floor";
|
|
363
|
+
// Both the guard and the early projections ask Pi about the very same converted context. Pi's cap uses its own estimator,
|
|
364
|
+
// whereas our hard line is in corrected units; fitting that line alone need not leave the branch's reply floor.
|
|
365
|
+
const requestClamp = (messages, ctx) => {
|
|
366
|
+
const context = normalizeContext({ messages: convertToLlm(messages) });
|
|
367
|
+
const cap = piClamp?.(ctx.model, context, ctx.model.maxTokens);
|
|
368
|
+
return { cap, leavesFloor: cap === undefined || cap >= limits(ctx).replyFloor,
|
|
369
|
+
estimate: piMath ? piMath.estimateContextTokens(context).tokens : 0 };
|
|
370
|
+
};
|
|
371
|
+
const projectedRequest = (messages, ctx, host, incoming, prompt, tools, cut) => {
|
|
372
|
+
const enabled = cut || historyToolsEnabled(ctx), shapes = toolShapes(historyToolNames(pi, tools, enabled));
|
|
373
|
+
const saved = messages.some(message => message.role === "system") ? messages :
|
|
374
|
+
[{ role: "system", content: prompt, toolsAdded: shapes, timestamp: 0 }, ...messages];
|
|
375
|
+
const declared = enabled ? historyToolDeclarations(saved, shapes, true) : saved;
|
|
376
|
+
return applyClips([...host, ...(enabled ? projectedMessages(declared, ctx, options.recallPolicy) : declared), ...incoming], clips);
|
|
377
|
+
};
|
|
378
|
+
const clampAboveRoom = (decision, ctx, room, target, total, project, replan, deepest) => {
|
|
379
|
+
if (options.requestReserve || !piClamp)
|
|
380
|
+
return decision;
|
|
381
|
+
const lastAttempt = () => {
|
|
382
|
+
const previousClips = new Map(clips);
|
|
383
|
+
const candidate = deepest();
|
|
384
|
+
if (candidate.kind === "cut" && fits(total(candidate), limits(ctx).hardLine, ctx) && requestClamp(project(candidate), ctx).leavesFloor)
|
|
385
|
+
return candidate;
|
|
386
|
+
clips.clear();
|
|
387
|
+
for (const [id, clip] of previousClips)
|
|
388
|
+
clips.set(id, clip);
|
|
389
|
+
return { kind: "fault", capacity: true, message: clampMessage };
|
|
390
|
+
};
|
|
391
|
+
for (let retry = 0;; retry++) {
|
|
392
|
+
if (decision.kind !== "cut" && decision.kind !== "none")
|
|
393
|
+
return decision;
|
|
394
|
+
if (corrected(total(decision), ctx) <= room)
|
|
395
|
+
return decision;
|
|
396
|
+
const check = requestClamp(project(decision), ctx);
|
|
397
|
+
if (check.leavesFloor)
|
|
398
|
+
return decision;
|
|
399
|
+
if (retry === 3)
|
|
400
|
+
return lastAttempt();
|
|
401
|
+
// Shrink in the planner's units by Pi's measured shortfall, at most three times. Once we reach the original room,
|
|
402
|
+
// use its existing band/room policy; admission below the room is deliberately unchanged.
|
|
403
|
+
target = Math.max(room, Math.floor(target * (ctx.model.contextWindow - piMargin - limits(ctx).replyFloor) / check.estimate));
|
|
404
|
+
decision = replan(target);
|
|
405
|
+
if (decision.kind === "defer" || decision.kind === "fault")
|
|
406
|
+
return lastAttempt();
|
|
407
|
+
}
|
|
408
|
+
};
|
|
361
409
|
// Pi reports a manual cut with nothing to do as a failed compaction; the thrown error lacks the event's "Compaction failed: " prefix.
|
|
362
410
|
const isNoWork = (message) => ["Already compacted", "Nothing to compact (session too small)"].includes(message?.replace(/^Compaction failed: /, "") ?? "");
|
|
363
411
|
// A missing model is configuration, not capacity.
|
|
@@ -433,7 +481,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
433
481
|
phase = "decided";
|
|
434
482
|
try {
|
|
435
483
|
syncHistoryTools(ctx);
|
|
436
|
-
await preflight(ctx, incomingTokens(event.text, event.images), ctx.getSystemPrompt(), pi.getActiveTools());
|
|
484
|
+
await preflight(ctx, incomingTokens(event.text, event.images), [{ role: "user", content: [{ type: "text", text: event.text }, ...(event.images ?? [])], timestamp: 0 }], ctx.getSystemPrompt(), pi.getActiveTools());
|
|
437
485
|
syncHistoryTools(ctx);
|
|
438
486
|
}
|
|
439
487
|
catch (error) {
|
|
@@ -535,7 +583,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
535
583
|
// A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
|
|
536
584
|
// cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
|
|
537
585
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
538
|
-
const
|
|
586
|
+
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
539
587
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
540
588
|
const mandatory = system + host, band = decisionBand(ctx, room, system);
|
|
541
589
|
const before = plannerMeasureFor(ctx)(saved);
|
|
@@ -561,7 +609,8 @@ export async function installCompaction(pi, options = {}) {
|
|
|
561
609
|
// 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
|
|
562
610
|
// from any run that ended. A retry is inside its run.
|
|
563
611
|
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun, admittedRun ? protectedPrompt(ctx) : undefined);
|
|
564
|
-
return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve
|
|
612
|
+
return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve,
|
|
613
|
+
messages: previewCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before }).buildSessionProjection().messages }
|
|
565
614
|
: { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
566
615
|
},
|
|
567
616
|
});
|
|
@@ -569,15 +618,14 @@ export async function installCompaction(pi, options = {}) {
|
|
|
569
618
|
let decision = decide();
|
|
570
619
|
const { hardLine } = limits(ctx);
|
|
571
620
|
if (!operatorManual && hardLine > room && decision.kind === "fault") {
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
decision = decide(hardLine, hardLine);
|
|
621
|
+
decision = fits(before + mandatory, hardLine, ctx) ? { kind: "none" } : decide(hardLine, hardLine);
|
|
622
|
+
decision = clampAboveRoom(decision, ctx, room, hardLine, candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory, candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, [], ctx.getSystemPrompt(), pi.getActiveTools(), candidate.kind === "cut"), target => target <= room ? decide() : decide(target, target), () => deepestDecision(ctx, operatorManual || !admittedRun, retain));
|
|
575
623
|
}
|
|
576
624
|
if (decision.kind === "cut") {
|
|
577
625
|
return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore, retain, operatorManual, event.preparation.previousSummary, { response, host, mandatory, target: decision.target, margin: room - band }) };
|
|
578
626
|
}
|
|
579
627
|
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
580
|
-
if (decision.kind === "none")
|
|
628
|
+
if (decision.kind === "none" || decision.kind === "defer")
|
|
581
629
|
return { cancel: true };
|
|
582
630
|
recorder.stop("no-cut");
|
|
583
631
|
ctx.ui.notify(decision.message, "warning");
|
|
@@ -609,21 +657,78 @@ export async function installCompaction(pi, options = {}) {
|
|
|
609
657
|
syncHistoryTools(ctx); });
|
|
610
658
|
pi.on("turn_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
611
659
|
pi.on("agent_settled", (_event, ctx) => syncHistoryTools(ctx));
|
|
660
|
+
// 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:
|
|
661
|
+
// 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)
|
|
662
|
+
// that ends normally clears it.
|
|
663
|
+
let lengthStop = false;
|
|
664
|
+
pi.on("agent_start", () => { lengthStop = false; });
|
|
665
|
+
pi.on("message_end", event => { if (event.message.role === "assistant")
|
|
666
|
+
lengthStop = event.message.stopReason === "length"; });
|
|
667
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
668
|
+
if (!lengthStop || !active())
|
|
669
|
+
return;
|
|
670
|
+
lengthStop = false;
|
|
671
|
+
const message = "pi-tuck: the last reply stopped at the model's output limit and may be cut off. Ask the agent to continue.";
|
|
672
|
+
try {
|
|
673
|
+
if (ctx.hasUI === false)
|
|
674
|
+
process.stderr.write(message + "\n");
|
|
675
|
+
else
|
|
676
|
+
ctx.ui.notify(message, "warning");
|
|
677
|
+
}
|
|
678
|
+
catch { /* ended */ } // with no UI (pi -p), stderr is the operator's
|
|
679
|
+
});
|
|
612
680
|
// 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.
|
|
613
|
-
const
|
|
681
|
+
const previewCut = (ctx, cut) => {
|
|
614
682
|
const header = ctx.sessionManager.getHeader();
|
|
615
683
|
if (!header)
|
|
616
684
|
throw new Error("Missing native session header for the prompt's cut");
|
|
617
685
|
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
618
|
-
preview.appendCompaction(cut.summary,
|
|
686
|
+
preview.appendCompaction(cut.summary, cut.firstKeptEntryId, 0, cut.details, true);
|
|
619
687
|
return preview;
|
|
620
688
|
};
|
|
689
|
+
const cutSession = (ctx, cut) => previewCut(ctx, { ...cut, firstKeptEntryId: keptFrom(cut, ctx) });
|
|
621
690
|
const withCut = (ctx, cut) => cutSession(ctx, cut).buildSessionProjection().messages;
|
|
691
|
+
// The shared final planner: preserve the compiler's prompt/protocol floor and spend nothing on optional history. Fresh text results
|
|
692
|
+
// get the smallest existing head/tail + recall-locator representation; their originals remain in the session, as for ordinary clips.
|
|
693
|
+
const deepestDecision = (ctx, newPrompt, retain) => {
|
|
694
|
+
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
695
|
+
if (!newPrompt) {
|
|
696
|
+
const at = saved.findLastIndex(message => message.role === "assistant" && !isDiscardedResponse(message));
|
|
697
|
+
const response = saved[at];
|
|
698
|
+
const calls = new Set(response?.role === "assistant" ? response.content.flatMap(block => block.type === "toolCall" ? [block.id] : []) : []);
|
|
699
|
+
const results = saved.slice(at + 1).filter((message) => message.role === "toolResult" && calls.has(message.toolCallId));
|
|
700
|
+
const entryIdOf = (id) => ctx.sessionManager.getBranch().findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === id)?.id;
|
|
701
|
+
for (const result of results) {
|
|
702
|
+
let low = 0, high = conversationTokens([result]) - 1;
|
|
703
|
+
if (!clipFor([result], entryIdOf, high).length)
|
|
704
|
+
continue;
|
|
705
|
+
while (low < high) {
|
|
706
|
+
const middle = Math.floor((low + high) / 2);
|
|
707
|
+
if (clipFor([result], entryIdOf, middle).length)
|
|
708
|
+
high = middle;
|
|
709
|
+
else
|
|
710
|
+
low = middle + 1;
|
|
711
|
+
}
|
|
712
|
+
for (const clip of clipFor([result], entryIdOf, low))
|
|
713
|
+
clips.set(clip.toolCallId, clip);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const before = measureFor(ctx)(saved);
|
|
717
|
+
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before,
|
|
718
|
+
previousSummary: saved.find(message => message.role === "compactionSummary")?.summary, newPrompt, reRenderSummary: true,
|
|
719
|
+
protectedPromptEntryId: newPrompt ? undefined : protectedPrompt(ctx), deepest: true }, ctx, 0, plannerMeasureFor(ctx), options.recallPolicy, retain);
|
|
720
|
+
if (!draft.compaction)
|
|
721
|
+
return { kind: "fault", capacity: Boolean(draft.capacityRejected), message: draft.unavailable };
|
|
722
|
+
const messages = previewCut(ctx, { type: "compaction", ...draft.compaction }).buildSessionProjection().messages;
|
|
723
|
+
assertNativeToolRounds(messages);
|
|
724
|
+
return { kind: "cut", target: "room", plan: { draft: true, after: measureFor(ctx)(messages), mode: draft.mode,
|
|
725
|
+
compaction: draft.compaction, reserve: 0, messages } };
|
|
726
|
+
};
|
|
622
727
|
// 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
|
|
623
728
|
// 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
|
|
624
729
|
// keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
|
|
625
730
|
// Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
|
|
626
|
-
const preflight = (ctx, incomingPart, observedPrompt, tools, hostSnapshot) => {
|
|
731
|
+
const preflight = (ctx, incomingPart, incomingMessages, observedPrompt, tools, hostSnapshot) => {
|
|
627
732
|
promptCut = undefined;
|
|
628
733
|
promptCutAt = ctx.sessionManager.getBranch().length;
|
|
629
734
|
if (!active())
|
|
@@ -638,7 +743,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
638
743
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
639
744
|
const system = requestSystemPart(observedPrompt, tools, saved, ctx.getSystemPrompt());
|
|
640
745
|
const band = decisionBand(ctx, room, system);
|
|
641
|
-
const
|
|
746
|
+
const hostInput = hostSnapshot ?? hostMessages(ctx), host = conversationTokens(hostInput), measure = measureFor(ctx), before = measure(saved);
|
|
642
747
|
const incoming = incomingPart + system, mandatory = incoming + host;
|
|
643
748
|
observeBudget(before + mandatory, ctx);
|
|
644
749
|
estimatedTokens = corrected(before + mandatory, ctx);
|
|
@@ -662,15 +767,15 @@ export async function installCompaction(pi, options = {}) {
|
|
|
662
767
|
return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
663
768
|
const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
|
|
664
769
|
assertNativeToolRounds(messages);
|
|
665
|
-
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
770
|
+
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve, messages };
|
|
666
771
|
},
|
|
667
772
|
});
|
|
668
773
|
let decision = gate.kind === "plan" ? decide(room, band, gate.opportunistic) : { kind: "defer" };
|
|
669
774
|
const { hardLine } = limits(ctx);
|
|
670
775
|
if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
decision =
|
|
776
|
+
decision = fits(before + mandatory, hardLine, ctx) ? { kind: "none" } :
|
|
777
|
+
fits(mandatory, hardLine, ctx) ? decide(hardLine, hardLine, false) : { kind: "defer" };
|
|
778
|
+
decision = clampAboveRoom(decision, ctx, room, hardLine, candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory, candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, incomingMessages, observedPrompt, tools, candidate.kind === "cut"), target => decide(target, target <= room ? band : target, false), () => deepestDecision(ctx, true, false));
|
|
674
779
|
}
|
|
675
780
|
if (decision.kind === "defer")
|
|
676
781
|
return;
|
|
@@ -693,7 +798,8 @@ export async function installCompaction(pi, options = {}) {
|
|
|
693
798
|
if (prompted)
|
|
694
799
|
access(); // decided on input; a failure latched there still stops the run
|
|
695
800
|
else
|
|
696
|
-
await preflight(ctx, incomingEventTokens(event),
|
|
801
|
+
await preflight(ctx, incomingEventTokens(event), [{ role: "user", timestamp: 0,
|
|
802
|
+
content: event.messageType === "custom" ? event.message.content : [{ type: "text", text: event.prompt }, ...(event.images ?? [])] }], event.systemPrompt, historyToolNames(pi, event.systemPromptOptions.selectedTools, historyToolsEnabled(ctx)));
|
|
697
803
|
// Pi declares tool changes before turn_start, then snapshots executable tools in prepareRequest. Introduce our tools in
|
|
698
804
|
// the request projection: recording an activation message here would shift every later session-global recall index.
|
|
699
805
|
if (historyToolsEnabled(ctx))
|
|
@@ -720,11 +826,11 @@ export async function installCompaction(pi, options = {}) {
|
|
|
720
826
|
const message = event.message;
|
|
721
827
|
const incoming = incomingTokens(typeof message.content === "string" ? message.content : message.content.flatMap(b => b.type === "text" ? [b.text] : []).join("\n"), typeof message.content === "string" ? [] : message.content.filter((b) => b.type === "image"));
|
|
722
828
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
723
|
-
const
|
|
829
|
+
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
724
830
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
725
831
|
if (fits(conversationTokens(saved) + system + incoming + host, limits(ctx).room, ctx))
|
|
726
832
|
return;
|
|
727
|
-
preflight(ctx, incoming, ctx.getSystemPrompt(), pi.getActiveTools(),
|
|
833
|
+
preflight(ctx, incoming, [message], ctx.getSystemPrompt(), pi.getActiveTools(), hostInput);
|
|
728
834
|
syncHistoryTools(ctx);
|
|
729
835
|
}
|
|
730
836
|
catch (error) {
|
|
@@ -750,7 +856,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
750
856
|
// request while tools still run, so it can be counted early: conservative, and nothing else would cut at that later boundary.
|
|
751
857
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
752
858
|
const band = decisionBand(ctx, room, system);
|
|
753
|
-
const
|
|
859
|
+
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
754
860
|
const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages) : 0;
|
|
755
861
|
const mandatory = system + host + queued;
|
|
756
862
|
// Without usable provider usage, Pi's own threshold uses its unwrapped message estimate as the compiler's density input.
|
|
@@ -777,12 +883,13 @@ export async function installCompaction(pi, options = {}) {
|
|
|
777
883
|
let floorKept;
|
|
778
884
|
let wholePromptFloor;
|
|
779
885
|
const protectedId = reason === "tool-loop" ? protectedPrompt(ctx) : undefined;
|
|
780
|
-
const decide = (targetRoom = room, targetBand = band) => {
|
|
886
|
+
const decide = (targetRoom = room, targetBand = band, required = false) => {
|
|
781
887
|
previews = [];
|
|
782
888
|
floorKept = undefined;
|
|
783
889
|
wholePromptFloor = undefined;
|
|
784
890
|
const before = measure(saved);
|
|
785
|
-
const gate =
|
|
891
|
+
const gate = required && fits(mandatory, targetRoom, ctx) ? { kind: "plan", opportunistic: false } :
|
|
892
|
+
gateCut({ total: before + mandatory, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
786
893
|
if (gate.kind !== "plan")
|
|
787
894
|
return gate;
|
|
788
895
|
return chooseCut({
|
|
@@ -811,7 +918,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
811
918
|
const messages = preview.buildSessionProjection().messages;
|
|
812
919
|
assertNativeToolRounds(messages);
|
|
813
920
|
previews.push(messages);
|
|
814
|
-
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
921
|
+
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve, messages };
|
|
815
922
|
},
|
|
816
923
|
});
|
|
817
924
|
};
|
|
@@ -821,7 +928,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
821
928
|
// clipBudgets states the budgets tried and why they are counted in the estimate's units; `kept` is measured from the previews this decision
|
|
822
929
|
// 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
|
|
823
930
|
// refusal stands.
|
|
824
|
-
const tryClips = (clipRoom, targetBand) => {
|
|
931
|
+
const tryClips = (clipRoom, targetBand, required = false) => {
|
|
825
932
|
if (decision.kind !== "fault" || !event.toolResults)
|
|
826
933
|
return;
|
|
827
934
|
const branch = ctx.sessionManager.getBranch();
|
|
@@ -834,7 +941,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
834
941
|
continue;
|
|
835
942
|
for (const clip of round)
|
|
836
943
|
clips.set(clip.toolCallId, clip);
|
|
837
|
-
const clippedDecision = decide(clipRoom, targetBand);
|
|
944
|
+
const clippedDecision = decide(clipRoom, targetBand, required);
|
|
838
945
|
if (clippedDecision.kind === "fault")
|
|
839
946
|
for (const clip of round)
|
|
840
947
|
clips.delete(clip.toolCallId);
|
|
@@ -851,9 +958,17 @@ export async function installCompaction(pi, options = {}) {
|
|
|
851
958
|
const { hardLine } = limits(ctx);
|
|
852
959
|
if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
|
|
853
960
|
if (fits(before + mandatory, hardLine, ctx))
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
961
|
+
decision = { kind: "none" };
|
|
962
|
+
else {
|
|
963
|
+
decision = decide(hardLine, hardLine);
|
|
964
|
+
tryClips(hardLine, hardLine);
|
|
965
|
+
}
|
|
966
|
+
decision = clampAboveRoom(decision, ctx, room, hardLine, candidate => (candidate.kind === "cut" ? candidate.plan.after : before) + mandatory, candidate => projectedRequest(candidate.kind === "cut" ? candidate.plan.messages : saved, ctx, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), candidate.kind === "cut"), target => {
|
|
967
|
+
const targetBand = target <= room ? band : target;
|
|
968
|
+
decision = decide(target, targetBand, target > room);
|
|
969
|
+
tryClips(target, targetBand, target > room);
|
|
970
|
+
return decision;
|
|
971
|
+
}, () => deepestDecision(ctx, false, true));
|
|
857
972
|
}
|
|
858
973
|
if (decision.kind === "fault" && reason === "tool-loop") {
|
|
859
974
|
if (wholePromptFloor !== undefined && !fits(wholePromptFloor, hardLine, ctx))
|
|
@@ -880,10 +995,71 @@ export async function installCompaction(pi, options = {}) {
|
|
|
880
995
|
setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
|
|
881
996
|
return { entries: [...event.entries, saved, ...(more?.entries ?? [])] };
|
|
882
997
|
};
|
|
998
|
+
const retryThinking = (event, ctx) => {
|
|
999
|
+
// Pi's supported boundary drafts commit before finishTurn honors `continue`. Omitting the selected failed response also prevents
|
|
1000
|
+
// Pi's later native length recovery from retrying it independently. The stored message and its output usage stay on the branch.
|
|
1001
|
+
const entries = [...(atBoundary(event, ctx, "run-end", false)?.entries ?? event.entries),
|
|
1002
|
+
{ type: "context_edit", targetId: event.messageEntryId, replacement: null }];
|
|
1003
|
+
if (thinkingRetryPending)
|
|
1004
|
+
return { entries, continue: false };
|
|
1005
|
+
const header = ctx.sessionManager.getHeader();
|
|
1006
|
+
if (!header)
|
|
1007
|
+
throw new Error("Missing native session header for the thinking-only retry");
|
|
1008
|
+
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
1009
|
+
for (const entry of entries) {
|
|
1010
|
+
if (entry.type === "context_edit")
|
|
1011
|
+
preview.appendContextEdit(entry.targetId, entry.replacement);
|
|
1012
|
+
else if (entry.type === "compaction")
|
|
1013
|
+
preview.appendCompaction(entry.summary, entry.firstKeptEntryId, 0, entry.details, true, entry.usage);
|
|
1014
|
+
else if (entry.type === "custom")
|
|
1015
|
+
preview.appendCustomEntry(entry.customType, entry.data);
|
|
1016
|
+
else
|
|
1017
|
+
preview.appendCustomMessageEntry(entry.customType, entry.content, entry.display, entry.details);
|
|
1018
|
+
}
|
|
1019
|
+
const view = Object.create(ctx, { sessionManager: { value: preview } });
|
|
1020
|
+
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
1021
|
+
// 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
|
|
1022
|
+
// as large as the deepest cut's, the turn is retried uncut, and no history is given up for nothing.
|
|
1023
|
+
const asIs = projectedRequest(preview.buildSessionProjection().messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), false);
|
|
1024
|
+
const asIsCheck = requestClamp(asIs, view);
|
|
1025
|
+
const previousClips = new Map(clips);
|
|
1026
|
+
const decision = deepestDecision(view, false, true);
|
|
1027
|
+
if (decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
|
|
1028
|
+
requestClamp(projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true), view).cap <= asIsCheck.cap) {
|
|
1029
|
+
clips.clear();
|
|
1030
|
+
for (const [id, clip] of previousClips)
|
|
1031
|
+
clips.set(id, clip);
|
|
1032
|
+
const { room, hardLine } = limits(view);
|
|
1033
|
+
thinkingRetryPending = true;
|
|
1034
|
+
return { entries: [...entries, { type: "custom", customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
|
|
1035
|
+
stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: false,
|
|
1036
|
+
piEstimateTokens: asIsCheck.estimate, piCap: asIsCheck.cap, room, hardLine, buildId: BUILD_ID } }], continue: true };
|
|
1037
|
+
}
|
|
1038
|
+
if (decision.kind !== "cut")
|
|
1039
|
+
throw new CapacityError(clampMessage);
|
|
1040
|
+
const messages = projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true);
|
|
1041
|
+
if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
|
|
1042
|
+
throw promptRefusal();
|
|
1043
|
+
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
|
|
1044
|
+
const tokens = system + conversationTokens(messages), check = requestClamp(messages, view);
|
|
1045
|
+
const { response, room, hardLine } = limits(view);
|
|
1046
|
+
if (!fits(tokens, hardLine, view) || check.cap === undefined || !check.leavesFloor)
|
|
1047
|
+
throw new CapacityError(clampMessage);
|
|
1048
|
+
const compaction = captureDraft(decision.plan.compaction, view, decision.plan.reserve, "length-retry", tokens, true, false, undefined, { response, host, mandatory: system + host + conversationTokens(event.context.pendingMessages), target: decision.target, margin: room - decisionBand(view, room, system) });
|
|
1049
|
+
thinkingRetryPending = true;
|
|
1050
|
+
pendingBoundaryCommit = true;
|
|
1051
|
+
return { entries: [...entries, { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId || null, details: compaction.details },
|
|
1052
|
+
{ type: "custom", customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
|
|
1053
|
+
stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: true,
|
|
1054
|
+
estimatedTokens: corrected(tokens, view), piEstimateTokens: check.estimate, piCap: check.cap, room, hardLine, buildId: BUILD_ID } }], continue: true };
|
|
1055
|
+
};
|
|
883
1056
|
pi.on("turn_end", (event, ctx) => {
|
|
884
1057
|
try {
|
|
885
1058
|
if (!active() || !admittedRun)
|
|
886
1059
|
return;
|
|
1060
|
+
if (thinkingOnlyLength(event.message) && !failure && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
|
|
1061
|
+
return retryThinking(event, ctx);
|
|
1062
|
+
thinkingRetryPending = false;
|
|
887
1063
|
const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
|
|
888
1064
|
settingsFor().getCompactionSettings(ctx.model).enabled;
|
|
889
1065
|
if (!promptCut && !decide)
|
|
@@ -996,16 +1172,15 @@ export async function installCompaction(pi, options = {}) {
|
|
|
996
1172
|
const tokens = system + conversationTokens(messages);
|
|
997
1173
|
observeBudget(tokens, ctx);
|
|
998
1174
|
estimatedTokens = corrected(tokens, ctx);
|
|
999
|
-
const { room, hardLine
|
|
1175
|
+
const { room, hardLine } = limits(ctx);
|
|
1000
1176
|
if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
|
|
1001
1177
|
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");
|
|
1002
1178
|
if (!fits(tokens, hardLine, ctx))
|
|
1003
1179
|
throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
|
|
1004
1180
|
// Pi's request estimator and its compaction estimator need not agree. The hard line is
|
|
1005
1181
|
// necessary; above the room, also require the real request clamp to preserve the floor.
|
|
1006
|
-
if (!options.requestReserve && estimatedTokens > room && piClamp &&
|
|
1007
|
-
|
|
1008
|
-
throw new CapacityError("Pi's request clamp leaves less than the reply floor");
|
|
1182
|
+
if (!options.requestReserve && estimatedTokens > room && piClamp && !requestClamp(messages, ctx).leavesFloor)
|
|
1183
|
+
throw new CapacityError(clampMessage);
|
|
1009
1184
|
if (estimatedTokens > room)
|
|
1010
1185
|
receipt("above-room", "Request exceeds the room and fits the hard line", ctx);
|
|
1011
1186
|
if (recorder.enabled && !recorder.waitingForCut)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type RecallPolicy } from './recall.js';
|
|
2
2
|
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
3
|
-
export declare const DIAGNOSTIC_BUILD = "
|
|
3
|
+
export declare const DIAGNOSTIC_BUILD = "e95edde95c614d01e1cbf6489134676fac0c1bbf6aa57ef639ffac1e7e105041";
|
|
4
4
|
export interface Receipt {
|
|
5
5
|
id: string;
|
|
6
6
|
build: string;
|
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import type { Messages } from './budget.js';
|
|
2
|
+
import type { AgentMessage, Messages } from './budget.js';
|
|
3
|
+
type SimpleOptions = typeof import('@earendil-works/pi-ai/api/simple-options');
|
|
4
|
+
type Estimate = typeof import('@earendil-works/pi-ai/utils/estimate');
|
|
5
|
+
export type PiRequestMath = {
|
|
6
|
+
clampMaxTokensToContext: SimpleOptions['clampMaxTokensToContext'];
|
|
7
|
+
estimateContextTokens: Estimate['estimateContextTokens'];
|
|
8
|
+
};
|
|
9
|
+
/** Pi's own request arithmetic: its reply clamp and the estimator behind it, as the running Pi has them.
|
|
10
|
+
* An installed extension cannot import these subpaths by name: Pi's extension loader aliases only pi-ai's root, compat, models, oauth and
|
|
11
|
+
* 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
|
|
12
|
+
* checkout), then from the running Pi's own install, beside its executable. If neither works (an SDK host), there is none, and callers
|
|
13
|
+
* keep their fallbacks. */
|
|
14
|
+
export declare function loadPiRequestMath(): Promise<PiRequestMath | undefined>;
|
|
3
15
|
/** Read Pi's private safety allowance through its public clamp, rather than copying it.
|
|
4
|
-
*
|
|
16
|
+
* Without Pi's request arithmetic, fall back to Pi 1.2–1.5's documented 4,096-token allowance.
|
|
5
17
|
*/
|
|
6
|
-
export declare function piContextSafetyMargin(): Promise<number>;
|
|
18
|
+
export declare function piContextSafetyMargin(math?: PiRequestMath): Promise<number>;
|
|
7
19
|
/** 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). */
|
|
8
20
|
export declare const REPLY_FLOOR_MIN = 3584;
|
|
21
|
+
/** A spent thinking budget that produced neither a visible answer nor work. Empty/text/tool replies keep the existing length recovery. */
|
|
22
|
+
export declare const thinkingOnlyLength: (message: AgentMessage) => boolean;
|
|
23
|
+
/** Thinking-only exhaustion uses the observed largest reply, not the full reserve. Every other failed length/overflow keeps its override. */
|
|
24
|
+
export declare const needsFullReplyReserve: (message: AgentMessage) => boolean;
|
|
9
25
|
/** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
|
|
10
26
|
export declare function branchReplyFloor(branch: readonly SessionEntry[], reserve: number): number;
|
|
11
27
|
/** Admission never promises more output than a model with a positive reply cap can produce. */
|
|
12
28
|
export declare function requestReplyFloor(branch: readonly SessionEntry[], reserve: number, maxTokens?: number): number;
|
|
13
29
|
/** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
|
|
14
30
|
export declare function carriesPrompt(messages: Messages, prompt: string): boolean;
|
|
31
|
+
export {};
|