pi-tuck 1.0.23 → 1.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +6 -3
- 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 +223 -43
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/request-capacity.d.ts +5 -1
- package/dist/src/request-capacity.js +12 -3
- package/dist/src/status.d.ts +2 -0
- package/dist/src/status.js +3 -0
- package/dist/src/tuck.d.ts +1 -0
- package/dist/src/tuck.js +8 -0
- package/package.json +1 -1
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +213 -51
- package/src/request-capacity.ts +15 -4
- package/src/status.ts +4 -1
- package/src/tuck.ts +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,59 @@
|
|
|
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.25] - 2026-11-10
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **A request above the room could be admitted and then refused, leaving the session stuck where native Pi would have sent.** pi-tuck checked
|
|
11
|
+
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
|
|
12
|
+
than pi-tuck's raw measure.
|
|
13
|
+
- Now each point that sends above the room checks Pi's own cap on the request it would send.
|
|
14
|
+
- 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
|
|
15
|
+
prompt. It refuses only if that leaves less than the floor too.
|
|
16
|
+
- In the stress test, 7 refusals now answer, 3 of which had stranded the session.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **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
|
|
21
|
+
request. It uses the deepest cut that still carries the whole prompt when that gives the reply more room; otherwise it retries uncut.
|
|
22
|
+
- 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
|
|
23
|
+
reserve would refuse every request when the setup alone is above the room.
|
|
24
|
+
- A retried reply that stops again ends the run.
|
|
25
|
+
- `/tuck` counts retries.
|
|
26
|
+
- **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".
|
|
27
|
+
It goes to stderr when there's no UI and never enters the model's context.
|
|
28
|
+
- **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
|
|
29
|
+
own reply cap on the smallest request that answers the prompt.
|
|
30
|
+
|
|
31
|
+
### Release gates
|
|
32
|
+
|
|
33
|
+
- **Shipped without a live gate, because its replay matches 1.0.24 exactly:** 13,946 requests on 29 recordings, 0 different, 0 lost and
|
|
34
|
+
0 dead tasks, clips 17 → 17.
|
|
35
|
+
- **Stress, 310 cells, strict:** 0 findings (1.0.24: 25 lines, 14 of them this defect). Tests (731) and CI.
|
|
36
|
+
|
|
37
|
+
## [1.0.24] - 2026-11-10
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- **A prompt's cut could reach a run it was not planned for.** pi-tuck plans a prompt's cut when the prompt arrives. When another extension
|
|
42
|
+
handled that prompt, no run started, and the plan waited for the next run:
|
|
43
|
+
- **A run started by a custom message** carried a cut sized for the prompt that never ran: deeper than needed.
|
|
44
|
+
- **After a move in the session tree (`/tree`),** the plan was applied on the new branch. Its kept entry was not on that branch, so the request
|
|
45
|
+
lost the custom message that started the run, even on a branch that needed no cut.
|
|
46
|
+
|
|
47
|
+
Now a run started by a custom message plans afresh, and a move in the tree drops any waiting plan.
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- Lifecycle tests: a handled prompt, then a custom-message run, and the same with a move in the tree first.
|
|
52
|
+
|
|
53
|
+
### Release gates
|
|
54
|
+
|
|
55
|
+
- **Shipped without a live gate, because its replay matches 1.0.23:** 13,946 requests on 29 recordings, read per request, 0 differ beyond the
|
|
56
|
+
base's own run-to-run noise; clips 17 → 17; 0 lost and 0 dead tasks.
|
|
57
|
+
- **Stress, 310 cells:** findings the same as 1.0.23, cell for cell. Tests (715) and CI.
|
|
58
|
+
|
|
6
59
|
## [1.0.23] - 2026-11-10
|
|
7
60
|
|
|
8
61
|
### 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
|
|
|
@@ -131,7 +131,10 @@ Known limits today:
|
|
|
131
131
|
- A host that patches a large section into the system prompt could get a refusal that a cut would have fit (1.0.0 to 1.0.16; fixed in 1.0.17). pi-tuck plans a prompt's cut when the prompt arrives, before other extensions change the system prompt. A host engine that adds a section of thousands of tokens for one turn could push that turn's first request over the room, and the final guard refused it. Now the guard plans the cut against the system prompt as finally sent, and refuses only if that cut does not fit either ([`CHANGELOG.md`](CHANGELOG.md) 1.0.17).
|
|
132
132
|
- Semantic recall never started in `pi -p` (1.0.0 to 1.0.20; fixed in 1.0.21). pi-tuck skipped semantic recall whenever Pi ran with no UI, to keep a one-shot run from starting the first-time download, and that also skipped a runtime that was already installed. Jobs run with `pi -p` searched by keyword only, while `tuck_search` said semantic recall was being set up. RPC mode was not affected ([`CHANGELOG.md`](CHANGELOG.md) 1.0.21).
|
|
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
|
+
- 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).
|
|
134
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).
|
|
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).
|
|
135
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).
|
|
136
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).
|
|
137
140
|
- pi-tuck 1.0.16 cost more in live chains than its notes first said. Against 1.0.13 at an 81,920 window, it took 1.48 times the turns on the tasks both resolved (95% interval 1.34 to 1.64), 74% more cuts and 1.66 times the tokens, with 56 of 60 tasks resolved against 55. Replay of single-task sessions cannot show this cost. 1.0.18 replaced its quote rules; in the 1.0.18 gate it took 22.27 turns per task against 29.35 for 1.0.16 and 20.22 for the 1.0.13 arm (different pairs of runs; reported, not claimed) ([`benchmark/RESULTS-V1016.md`](benchmark/RESULTS-V1016.md), [`CHANGELOG.md`](CHANGELOG.md) 1.0.18).
|
|
@@ -190,7 +193,7 @@ A bad file is reported at session start, and pi-tuck uses the defaults.
|
|
|
190
193
|
|
|
191
194
|
pi-tuck keeps room for the model's reply. Since 1.0.20 that room is Pi's `compaction.reserveTokens` (default 16,384), the same as native Pi. It rises to the model's `maxTokens` for the rest of the branch only after a reply on that branch did not fit: a reply that stopped for length, or a request refused as a context overflow. A transient error such as a 503 keeps the native room. This is read from the current branch at each decision, so resume, fork and `/tree` follow the branch, and a new session starts at `reserveTokens` again. A host that passes its own reserve keeps its own. Before 1.0.20, pi-tuck always reserved the larger of the two, so with a large `maxTokens` it cut earlier than native Pi.
|
|
192
195
|
|
|
193
|
-
Since 1.0.22, pi-tuck refuses a request only
|
|
196
|
+
Since 1.0.22, pi-tuck refuses a request only past a line near the window. Pi sets each request's reply cap to the window less its own estimate of the request less a 4,096-token safety margin, and since 1.0.23 pi-tuck reads that margin from Pi. A request above the room goes out only if Pi's own cap still leaves the reply floor. The floor is the larger of the biggest reply seen on the branch and 3,584 tokens, capped at the model's own reply cap. After a reply that stopped for length or was refused as a context overflow, the floor is the reserve. The line never sits below the room, where 1.0.21 also sends. Before it refuses, pi-tuck cuts to the band, cuts to the room, makes a smaller summary and clips a fresh tool result, in that order. A run's request always carries the whole prompt that started the run: if it cannot fit after every reduction, pi-tuck stops the run with a notice saying to shorten the prompt or use a larger window. Each refusal, and each request sent above the room, leaves one `pi-tuck-capacity` entry in the session; it is never sent to the model, and never appears in summaries or recall.
|
|
194
197
|
|
|
195
198
|
pi-tuck estimates the request's size, and it scales that estimate by the worst gap it has seen between its count and the provider's. In a small window, an upstream that spends far more context on tool schemas (some OpenRouter upstreams do) can leave too little room. If a small window refuses, use a larger window, or pin one upstream as below.
|
|
196
199
|
|
package/dist/src/build-id.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const BUILD_ID = "
|
|
1
|
+
export declare const BUILD_ID = "48d3f777f203edb56b29154215540eb8245182237d18cdc50c6cbe704ea28e7e";
|
package/dist/src/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "48d3f777f203edb56b29154215540eb8245182237d18cdc50c6cbe704ea28e7e";
|
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,16 +1,17 @@
|
|
|
1
1
|
import { textOf } from "./tuck/core/content.js";
|
|
2
|
-
import { requestReplyFloor, carriesPrompt, piContextSafetyMargin } from "./request-capacity.js";
|
|
2
|
+
import { requestReplyFloor, carriesPrompt, needsFullReplyReserve, thinkingOnlyLength, piContextSafetyMargin } from "./request-capacity.js";
|
|
3
3
|
import { BUILD_ID } from "./build-id.js";
|
|
4
4
|
import { createSemanticRecall, budgetSemantic } 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";
|
|
13
13
|
import { getCurrentSystemMessage, isContextOverflow, normalizeContext } from "@earendil-works/pi-ai";
|
|
14
|
+
import { estimateContextTokens } from "@earendil-works/pi-ai/utils/estimate";
|
|
14
15
|
import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
15
16
|
import { Type } from "typebox";
|
|
16
17
|
import { Check } from "typebox/value";
|
|
@@ -105,7 +106,7 @@ export function seedWindow(branch, key) {
|
|
|
105
106
|
}
|
|
106
107
|
export async function installCompaction(pi, options = {}) {
|
|
107
108
|
const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin();
|
|
108
|
-
const piClamp =
|
|
109
|
+
const piClamp = await import('@earendil-works/pi-ai/api/simple-options')
|
|
109
110
|
.then(module => module.clampMaxTokensToContext).catch(() => undefined);
|
|
110
111
|
const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
|
|
111
112
|
if (!validCutAt(cutAt))
|
|
@@ -224,6 +225,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
224
225
|
// Pi's native start/settled events bracket the run and its post-turn cuts.
|
|
225
226
|
let admittedRun = false;
|
|
226
227
|
let runPrompt;
|
|
228
|
+
let thinkingRetryPending = false;
|
|
227
229
|
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
230
|
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
231
|
let estimatedTokens = 0;
|
|
@@ -301,21 +303,21 @@ export async function installCompaction(pi, options = {}) {
|
|
|
301
303
|
if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio))
|
|
302
304
|
learn(ratio);
|
|
303
305
|
});
|
|
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; });
|
|
306
|
+
pi.on("agent_start", () => { admittedRun = true; thinkingRetryPending = false; pending = undefined; phase = "idle"; });
|
|
307
|
+
pi.on("agent_settled", () => { admittedRun = false; runPrompt = undefined; thinkingRetryPending = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
|
|
306
308
|
const active = () => options.active?.() ?? true;
|
|
307
309
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
308
310
|
// 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
|
-
//
|
|
311
|
+
// 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
|
|
312
|
+
// length stop uses branchReplyFloor's observed output instead; reserving the catalogue maximum would strand setups above the room.
|
|
313
|
+
// 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
314
|
// fork, /tree or a new session.
|
|
312
315
|
const requestReserve = (ctx) => {
|
|
313
316
|
const host = options.requestReserve?.(ctx);
|
|
314
317
|
const budget = host ?? { response: settingsFor().getCompactionSettings(ctx.model).reserveTokens };
|
|
315
318
|
if (!Number.isSafeInteger(budget.response) || budget.response < 0)
|
|
316
319
|
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))));
|
|
320
|
+
const floor = host ? host.responseExact !== true : ctx.sessionManager.getBranch().some(entry => entry.type === "message" && needsFullReplyReserve(entry.message));
|
|
319
321
|
return { response: floor ? Math.max(budget.response, ctx.model?.maxTokens ?? 0) : budget.response };
|
|
320
322
|
};
|
|
321
323
|
const inputRoom = (response, ctx) => {
|
|
@@ -345,7 +347,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
345
347
|
// 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
348
|
// (an operator's /compact repairs a session whose saved summary is foreign). Identical to the strict one for this compactor's summaries.
|
|
347
349
|
const plannerMeasureFor = (ctx) => Object.assign((messages) => measurableConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy), { messageTokens: (message) => conversationTokens(applyClips([message], clips)) });
|
|
348
|
-
const
|
|
350
|
+
const hostMessages = (ctx) => options.hostMessages?.(ctx) ?? [];
|
|
349
351
|
const toolShapes = (names) => {
|
|
350
352
|
const selected = new Set(names);
|
|
351
353
|
return pi.getAllTools().filter(tool => selected.has(tool.name)).map(tool => ({ name: tool.name, description: tool.description, parameters: tool.parameters }));
|
|
@@ -358,6 +360,53 @@ export async function installCompaction(pi, options = {}) {
|
|
|
358
360
|
const future = saved.some(message => message.role === "system") ? historyToolDeclarations(saved, shapes, true) : saved;
|
|
359
361
|
return systemPart(observedPrompt, shapes, future, guardPrompt);
|
|
360
362
|
};
|
|
363
|
+
const clampMessage = "Pi's request clamp leaves less than the reply floor";
|
|
364
|
+
// Both the guard and the early projections ask Pi about the very same converted context. Pi's cap uses its own estimator,
|
|
365
|
+
// whereas our hard line is in corrected units; fitting that line alone need not leave the branch's reply floor.
|
|
366
|
+
const requestClamp = (messages, ctx) => {
|
|
367
|
+
const context = normalizeContext({ messages: convertToLlm(messages) });
|
|
368
|
+
const cap = piClamp?.(ctx.model, context, ctx.model.maxTokens);
|
|
369
|
+
return { cap, leavesFloor: cap === undefined || cap >= limits(ctx).replyFloor,
|
|
370
|
+
estimate: estimateContextTokens(context).tokens };
|
|
371
|
+
};
|
|
372
|
+
const projectedRequest = (messages, ctx, host, incoming, prompt, tools, cut) => {
|
|
373
|
+
const enabled = cut || historyToolsEnabled(ctx), shapes = toolShapes(historyToolNames(pi, tools, enabled));
|
|
374
|
+
const saved = messages.some(message => message.role === "system") ? messages :
|
|
375
|
+
[{ role: "system", content: prompt, toolsAdded: shapes, timestamp: 0 }, ...messages];
|
|
376
|
+
const declared = enabled ? historyToolDeclarations(saved, shapes, true) : saved;
|
|
377
|
+
return applyClips([...host, ...(enabled ? projectedMessages(declared, ctx, options.recallPolicy) : declared), ...incoming], clips);
|
|
378
|
+
};
|
|
379
|
+
const clampAboveRoom = (decision, ctx, room, target, total, project, replan, deepest) => {
|
|
380
|
+
if (options.requestReserve || !piClamp)
|
|
381
|
+
return decision;
|
|
382
|
+
const lastAttempt = () => {
|
|
383
|
+
const previousClips = new Map(clips);
|
|
384
|
+
const candidate = deepest();
|
|
385
|
+
if (candidate.kind === "cut" && fits(total(candidate), limits(ctx).hardLine, ctx) && requestClamp(project(candidate), ctx).leavesFloor)
|
|
386
|
+
return candidate;
|
|
387
|
+
clips.clear();
|
|
388
|
+
for (const [id, clip] of previousClips)
|
|
389
|
+
clips.set(id, clip);
|
|
390
|
+
return { kind: "fault", capacity: true, message: clampMessage };
|
|
391
|
+
};
|
|
392
|
+
for (let retry = 0;; retry++) {
|
|
393
|
+
if (decision.kind !== "cut" && decision.kind !== "none")
|
|
394
|
+
return decision;
|
|
395
|
+
if (corrected(total(decision), ctx) <= room)
|
|
396
|
+
return decision;
|
|
397
|
+
const check = requestClamp(project(decision), ctx);
|
|
398
|
+
if (check.leavesFloor)
|
|
399
|
+
return decision;
|
|
400
|
+
if (retry === 3)
|
|
401
|
+
return lastAttempt();
|
|
402
|
+
// Shrink in the planner's units by Pi's measured shortfall, at most three times. Once we reach the original room,
|
|
403
|
+
// use its existing band/room policy; admission below the room is deliberately unchanged.
|
|
404
|
+
target = Math.max(room, Math.floor(target * (ctx.model.contextWindow - piMargin - limits(ctx).replyFloor) / check.estimate));
|
|
405
|
+
decision = replan(target);
|
|
406
|
+
if (decision.kind === "defer" || decision.kind === "fault")
|
|
407
|
+
return lastAttempt();
|
|
408
|
+
}
|
|
409
|
+
};
|
|
361
410
|
// Pi reports a manual cut with nothing to do as a failed compaction; the thrown error lacks the event's "Compaction failed: " prefix.
|
|
362
411
|
const isNoWork = (message) => ["Already compacted", "Nothing to compact (session too small)"].includes(message?.replace(/^Compaction failed: /, "") ?? "");
|
|
363
412
|
// A missing model is configuration, not capacity.
|
|
@@ -433,7 +482,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
433
482
|
phase = "decided";
|
|
434
483
|
try {
|
|
435
484
|
syncHistoryTools(ctx);
|
|
436
|
-
await preflight(ctx, incomingTokens(event.text, event.images), ctx.getSystemPrompt(), pi.getActiveTools());
|
|
485
|
+
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
486
|
syncHistoryTools(ctx);
|
|
438
487
|
}
|
|
439
488
|
catch (error) {
|
|
@@ -535,7 +584,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
535
584
|
// A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
|
|
536
585
|
// cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
|
|
537
586
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
538
|
-
const
|
|
587
|
+
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
539
588
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
540
589
|
const mandatory = system + host, band = decisionBand(ctx, room, system);
|
|
541
590
|
const before = plannerMeasureFor(ctx)(saved);
|
|
@@ -561,7 +610,8 @@ export async function installCompaction(pi, options = {}) {
|
|
|
561
610
|
// 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
611
|
// from any run that ended. A retry is inside its run.
|
|
563
612
|
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
|
|
613
|
+
return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve,
|
|
614
|
+
messages: previewCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before }).buildSessionProjection().messages }
|
|
565
615
|
: { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
566
616
|
},
|
|
567
617
|
});
|
|
@@ -569,15 +619,14 @@ export async function installCompaction(pi, options = {}) {
|
|
|
569
619
|
let decision = decide();
|
|
570
620
|
const { hardLine } = limits(ctx);
|
|
571
621
|
if (!operatorManual && hardLine > room && decision.kind === "fault") {
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
decision = decide(hardLine, hardLine);
|
|
622
|
+
decision = fits(before + mandatory, hardLine, ctx) ? { kind: "none" } : decide(hardLine, hardLine);
|
|
623
|
+
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
624
|
}
|
|
576
625
|
if (decision.kind === "cut") {
|
|
577
626
|
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
627
|
}
|
|
579
628
|
// An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
|
|
580
|
-
if (decision.kind === "none")
|
|
629
|
+
if (decision.kind === "none" || decision.kind === "defer")
|
|
581
630
|
return { cancel: true };
|
|
582
631
|
recorder.stop("no-cut");
|
|
583
632
|
ctx.ui.notify(decision.message, "warning");
|
|
@@ -602,26 +651,85 @@ export async function installCompaction(pi, options = {}) {
|
|
|
602
651
|
const syncHistoryTools = (ctx) => { if (active())
|
|
603
652
|
applyHistoryTools(pi, historyToolsEnabled(ctx)); };
|
|
604
653
|
pi.on("session_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
605
|
-
|
|
654
|
+
// A plan decided on `input` is for the branch it was decided on: moving in the tree leaves it planned for another branch, whose kept entry
|
|
655
|
+
// may not be on this one.
|
|
656
|
+
pi.on("session_tree", (_event, ctx) => { promptCut = undefined; phase = "idle"; syncHistoryTools(ctx); });
|
|
606
657
|
pi.on("session_compact", (_event, ctx) => { if (!admittedRun)
|
|
607
658
|
syncHistoryTools(ctx); });
|
|
608
659
|
pi.on("turn_start", (_event, ctx) => syncHistoryTools(ctx));
|
|
609
660
|
pi.on("agent_settled", (_event, ctx) => syncHistoryTools(ctx));
|
|
661
|
+
// 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:
|
|
662
|
+
// 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)
|
|
663
|
+
// that ends normally clears it.
|
|
664
|
+
let lengthStop = false;
|
|
665
|
+
pi.on("agent_start", () => { lengthStop = false; });
|
|
666
|
+
pi.on("message_end", event => { if (event.message.role === "assistant")
|
|
667
|
+
lengthStop = event.message.stopReason === "length"; });
|
|
668
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
669
|
+
if (!lengthStop || !active())
|
|
670
|
+
return;
|
|
671
|
+
lengthStop = false;
|
|
672
|
+
const message = "pi-tuck: the last reply stopped at the model's output limit and may be cut off. Ask the agent to continue.";
|
|
673
|
+
try {
|
|
674
|
+
if (ctx.hasUI === false)
|
|
675
|
+
process.stderr.write(message + "\n");
|
|
676
|
+
else
|
|
677
|
+
ctx.ui.notify(message, "warning");
|
|
678
|
+
}
|
|
679
|
+
catch { /* ended */ } // with no UI (pi -p), stderr is the operator's
|
|
680
|
+
});
|
|
610
681
|
// 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.
|
|
611
|
-
const
|
|
682
|
+
const previewCut = (ctx, cut) => {
|
|
612
683
|
const header = ctx.sessionManager.getHeader();
|
|
613
684
|
if (!header)
|
|
614
685
|
throw new Error("Missing native session header for the prompt's cut");
|
|
615
686
|
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
616
|
-
preview.appendCompaction(cut.summary,
|
|
687
|
+
preview.appendCompaction(cut.summary, cut.firstKeptEntryId, 0, cut.details, true);
|
|
617
688
|
return preview;
|
|
618
689
|
};
|
|
690
|
+
const cutSession = (ctx, cut) => previewCut(ctx, { ...cut, firstKeptEntryId: keptFrom(cut, ctx) });
|
|
619
691
|
const withCut = (ctx, cut) => cutSession(ctx, cut).buildSessionProjection().messages;
|
|
692
|
+
// The shared final planner: preserve the compiler's prompt/protocol floor and spend nothing on optional history. Fresh text results
|
|
693
|
+
// get the smallest existing head/tail + recall-locator representation; their originals remain in the session, as for ordinary clips.
|
|
694
|
+
const deepestDecision = (ctx, newPrompt, retain) => {
|
|
695
|
+
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
696
|
+
if (!newPrompt) {
|
|
697
|
+
const at = saved.findLastIndex(message => message.role === "assistant" && !isDiscardedResponse(message));
|
|
698
|
+
const response = saved[at];
|
|
699
|
+
const calls = new Set(response?.role === "assistant" ? response.content.flatMap(block => block.type === "toolCall" ? [block.id] : []) : []);
|
|
700
|
+
const results = saved.slice(at + 1).filter((message) => message.role === "toolResult" && calls.has(message.toolCallId));
|
|
701
|
+
const entryIdOf = (id) => ctx.sessionManager.getBranch().findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === id)?.id;
|
|
702
|
+
for (const result of results) {
|
|
703
|
+
let low = 0, high = conversationTokens([result]) - 1;
|
|
704
|
+
if (!clipFor([result], entryIdOf, high).length)
|
|
705
|
+
continue;
|
|
706
|
+
while (low < high) {
|
|
707
|
+
const middle = Math.floor((low + high) / 2);
|
|
708
|
+
if (clipFor([result], entryIdOf, middle).length)
|
|
709
|
+
high = middle;
|
|
710
|
+
else
|
|
711
|
+
low = middle + 1;
|
|
712
|
+
}
|
|
713
|
+
for (const clip of clipFor([result], entryIdOf, low))
|
|
714
|
+
clips.set(clip.toolCallId, clip);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const before = measureFor(ctx)(saved);
|
|
718
|
+
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before,
|
|
719
|
+
previousSummary: saved.find(message => message.role === "compactionSummary")?.summary, newPrompt, reRenderSummary: true,
|
|
720
|
+
protectedPromptEntryId: newPrompt ? undefined : protectedPrompt(ctx), deepest: true }, ctx, 0, plannerMeasureFor(ctx), options.recallPolicy, retain);
|
|
721
|
+
if (!draft.compaction)
|
|
722
|
+
return { kind: "fault", capacity: Boolean(draft.capacityRejected), message: draft.unavailable };
|
|
723
|
+
const messages = previewCut(ctx, { type: "compaction", ...draft.compaction }).buildSessionProjection().messages;
|
|
724
|
+
assertNativeToolRounds(messages);
|
|
725
|
+
return { kind: "cut", target: "room", plan: { draft: true, after: measureFor(ctx)(messages), mode: draft.mode,
|
|
726
|
+
compaction: draft.compaction, reserve: 0, messages } };
|
|
727
|
+
};
|
|
620
728
|
// 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
|
|
621
729
|
// 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
|
|
622
730
|
// keepRecentTokens, and where those are the whole conversation it has nothing to cut, so every later prompt was refused for good (t06 at
|
|
623
731
|
// Pi's defaults on a 32k window). Each request of the run carries the cut, and the run's first boundary saves it.
|
|
624
|
-
const preflight = (ctx, incomingPart, observedPrompt, tools, hostSnapshot) => {
|
|
732
|
+
const preflight = (ctx, incomingPart, incomingMessages, observedPrompt, tools, hostSnapshot) => {
|
|
625
733
|
promptCut = undefined;
|
|
626
734
|
promptCutAt = ctx.sessionManager.getBranch().length;
|
|
627
735
|
if (!active())
|
|
@@ -636,7 +744,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
636
744
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
637
745
|
const system = requestSystemPart(observedPrompt, tools, saved, ctx.getSystemPrompt());
|
|
638
746
|
const band = decisionBand(ctx, room, system);
|
|
639
|
-
const
|
|
747
|
+
const hostInput = hostSnapshot ?? hostMessages(ctx), host = conversationTokens(hostInput), measure = measureFor(ctx), before = measure(saved);
|
|
640
748
|
const incoming = incomingPart + system, mandatory = incoming + host;
|
|
641
749
|
observeBudget(before + mandatory, ctx);
|
|
642
750
|
estimatedTokens = corrected(before + mandatory, ctx);
|
|
@@ -660,15 +768,15 @@ export async function installCompaction(pi, options = {}) {
|
|
|
660
768
|
return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
661
769
|
const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
|
|
662
770
|
assertNativeToolRounds(messages);
|
|
663
|
-
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
771
|
+
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve, messages };
|
|
664
772
|
},
|
|
665
773
|
});
|
|
666
774
|
let decision = gate.kind === "plan" ? decide(room, band, gate.opportunistic) : { kind: "defer" };
|
|
667
775
|
const { hardLine } = limits(ctx);
|
|
668
776
|
if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
decision =
|
|
777
|
+
decision = fits(before + mandatory, hardLine, ctx) ? { kind: "none" } :
|
|
778
|
+
fits(mandatory, hardLine, ctx) ? decide(hardLine, hardLine, false) : { kind: "defer" };
|
|
779
|
+
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));
|
|
672
780
|
}
|
|
673
781
|
if (decision.kind === "defer")
|
|
674
782
|
return;
|
|
@@ -681,7 +789,9 @@ export async function installCompaction(pi, options = {}) {
|
|
|
681
789
|
promptCut = { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId, tokensBefore: before, details: compaction.details };
|
|
682
790
|
};
|
|
683
791
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
684
|
-
|
|
792
|
+
// A decided plan is for the prompt `input` saw. A run that a custom message starts is not that run (another extension handled the
|
|
793
|
+
// input, so no run started for it): plan afresh.
|
|
794
|
+
const prompted = phase === "decided" && event.messageType !== "custom";
|
|
685
795
|
phase = "starting";
|
|
686
796
|
runPrompt = event.messageType === "custom" ? undefined : event.prompt;
|
|
687
797
|
try {
|
|
@@ -689,7 +799,8 @@ export async function installCompaction(pi, options = {}) {
|
|
|
689
799
|
if (prompted)
|
|
690
800
|
access(); // decided on input; a failure latched there still stops the run
|
|
691
801
|
else
|
|
692
|
-
await preflight(ctx, incomingEventTokens(event),
|
|
802
|
+
await preflight(ctx, incomingEventTokens(event), [{ role: "user", timestamp: 0,
|
|
803
|
+
content: event.messageType === "custom" ? event.message.content : [{ type: "text", text: event.prompt }, ...(event.images ?? [])] }], event.systemPrompt, historyToolNames(pi, event.systemPromptOptions.selectedTools, historyToolsEnabled(ctx)));
|
|
693
804
|
// Pi declares tool changes before turn_start, then snapshots executable tools in prepareRequest. Introduce our tools in
|
|
694
805
|
// the request projection: recording an activation message here would shift every later session-global recall index.
|
|
695
806
|
if (historyToolsEnabled(ctx))
|
|
@@ -716,11 +827,11 @@ export async function installCompaction(pi, options = {}) {
|
|
|
716
827
|
const message = event.message;
|
|
717
828
|
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"));
|
|
718
829
|
const saved = ctx.sessionManager.buildSessionProjection().messages;
|
|
719
|
-
const
|
|
830
|
+
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
720
831
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
721
832
|
if (fits(conversationTokens(saved) + system + incoming + host, limits(ctx).room, ctx))
|
|
722
833
|
return;
|
|
723
|
-
preflight(ctx, incoming, ctx.getSystemPrompt(), pi.getActiveTools(),
|
|
834
|
+
preflight(ctx, incoming, [message], ctx.getSystemPrompt(), pi.getActiveTools(), hostInput);
|
|
724
835
|
syncHistoryTools(ctx);
|
|
725
836
|
}
|
|
726
837
|
catch (error) {
|
|
@@ -746,7 +857,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
746
857
|
// request while tools still run, so it can be counted early: conservative, and nothing else would cut at that later boundary.
|
|
747
858
|
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
|
|
748
859
|
const band = decisionBand(ctx, room, system);
|
|
749
|
-
const
|
|
860
|
+
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
750
861
|
const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages) : 0;
|
|
751
862
|
const mandatory = system + host + queued;
|
|
752
863
|
// Without usable provider usage, Pi's own threshold uses its unwrapped message estimate as the compiler's density input.
|
|
@@ -773,12 +884,13 @@ export async function installCompaction(pi, options = {}) {
|
|
|
773
884
|
let floorKept;
|
|
774
885
|
let wholePromptFloor;
|
|
775
886
|
const protectedId = reason === "tool-loop" ? protectedPrompt(ctx) : undefined;
|
|
776
|
-
const decide = (targetRoom = room, targetBand = band) => {
|
|
887
|
+
const decide = (targetRoom = room, targetBand = band, required = false) => {
|
|
777
888
|
previews = [];
|
|
778
889
|
floorKept = undefined;
|
|
779
890
|
wholePromptFloor = undefined;
|
|
780
891
|
const before = measure(saved);
|
|
781
|
-
const gate =
|
|
892
|
+
const gate = required && fits(mandatory, targetRoom, ctx) ? { kind: "plan", opportunistic: false } :
|
|
893
|
+
gateCut({ total: before + mandatory, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx) });
|
|
782
894
|
if (gate.kind !== "plan")
|
|
783
895
|
return gate;
|
|
784
896
|
return chooseCut({
|
|
@@ -807,7 +919,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
807
919
|
const messages = preview.buildSessionProjection().messages;
|
|
808
920
|
assertNativeToolRounds(messages);
|
|
809
921
|
previews.push(messages);
|
|
810
|
-
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
922
|
+
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve, messages };
|
|
811
923
|
},
|
|
812
924
|
});
|
|
813
925
|
};
|
|
@@ -817,7 +929,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
817
929
|
// clipBudgets states the budgets tried and why they are counted in the estimate's units; `kept` is measured from the previews this decision
|
|
818
930
|
// 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
|
|
819
931
|
// refusal stands.
|
|
820
|
-
const tryClips = (clipRoom, targetBand) => {
|
|
932
|
+
const tryClips = (clipRoom, targetBand, required = false) => {
|
|
821
933
|
if (decision.kind !== "fault" || !event.toolResults)
|
|
822
934
|
return;
|
|
823
935
|
const branch = ctx.sessionManager.getBranch();
|
|
@@ -830,7 +942,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
830
942
|
continue;
|
|
831
943
|
for (const clip of round)
|
|
832
944
|
clips.set(clip.toolCallId, clip);
|
|
833
|
-
const clippedDecision = decide(clipRoom, targetBand);
|
|
945
|
+
const clippedDecision = decide(clipRoom, targetBand, required);
|
|
834
946
|
if (clippedDecision.kind === "fault")
|
|
835
947
|
for (const clip of round)
|
|
836
948
|
clips.delete(clip.toolCallId);
|
|
@@ -847,9 +959,17 @@ export async function installCompaction(pi, options = {}) {
|
|
|
847
959
|
const { hardLine } = limits(ctx);
|
|
848
960
|
if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
|
|
849
961
|
if (fits(before + mandatory, hardLine, ctx))
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
962
|
+
decision = { kind: "none" };
|
|
963
|
+
else {
|
|
964
|
+
decision = decide(hardLine, hardLine);
|
|
965
|
+
tryClips(hardLine, hardLine);
|
|
966
|
+
}
|
|
967
|
+
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 => {
|
|
968
|
+
const targetBand = target <= room ? band : target;
|
|
969
|
+
decision = decide(target, targetBand, target > room);
|
|
970
|
+
tryClips(target, targetBand, target > room);
|
|
971
|
+
return decision;
|
|
972
|
+
}, () => deepestDecision(ctx, false, true));
|
|
853
973
|
}
|
|
854
974
|
if (decision.kind === "fault" && reason === "tool-loop") {
|
|
855
975
|
if (wholePromptFloor !== undefined && !fits(wholePromptFloor, hardLine, ctx))
|
|
@@ -876,10 +996,71 @@ export async function installCompaction(pi, options = {}) {
|
|
|
876
996
|
setImmediate(() => { semantic.indexAtCut(semanticProvider(ctx)); });
|
|
877
997
|
return { entries: [...event.entries, saved, ...(more?.entries ?? [])] };
|
|
878
998
|
};
|
|
999
|
+
const retryThinking = (event, ctx) => {
|
|
1000
|
+
// Pi's supported boundary drafts commit before finishTurn honors `continue`. Omitting the selected failed response also prevents
|
|
1001
|
+
// Pi's later native length recovery from retrying it independently. The stored message and its output usage stay on the branch.
|
|
1002
|
+
const entries = [...(atBoundary(event, ctx, "run-end", false)?.entries ?? event.entries),
|
|
1003
|
+
{ type: "context_edit", targetId: event.messageEntryId, replacement: null }];
|
|
1004
|
+
if (thinkingRetryPending)
|
|
1005
|
+
return { entries, continue: false };
|
|
1006
|
+
const header = ctx.sessionManager.getHeader();
|
|
1007
|
+
if (!header)
|
|
1008
|
+
throw new Error("Missing native session header for the thinking-only retry");
|
|
1009
|
+
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
1010
|
+
for (const entry of entries) {
|
|
1011
|
+
if (entry.type === "context_edit")
|
|
1012
|
+
preview.appendContextEdit(entry.targetId, entry.replacement);
|
|
1013
|
+
else if (entry.type === "compaction")
|
|
1014
|
+
preview.appendCompaction(entry.summary, entry.firstKeptEntryId, 0, entry.details, true, entry.usage);
|
|
1015
|
+
else if (entry.type === "custom")
|
|
1016
|
+
preview.appendCustomEntry(entry.customType, entry.data);
|
|
1017
|
+
else
|
|
1018
|
+
preview.appendCustomMessageEntry(entry.customType, entry.content, entry.display, entry.details);
|
|
1019
|
+
}
|
|
1020
|
+
const view = Object.create(ctx, { sessionManager: { value: preview } });
|
|
1021
|
+
const hostInput = hostMessages(ctx), host = conversationTokens(hostInput);
|
|
1022
|
+
// 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
|
|
1023
|
+
// as large as the deepest cut's, the turn is retried uncut, and no history is given up for nothing.
|
|
1024
|
+
const asIs = projectedRequest(preview.buildSessionProjection().messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), false);
|
|
1025
|
+
const asIsCheck = requestClamp(asIs, view);
|
|
1026
|
+
const previousClips = new Map(clips);
|
|
1027
|
+
const decision = deepestDecision(view, false, true);
|
|
1028
|
+
if (decision.kind === "cut" && asIsCheck.cap !== undefined && asIsCheck.leavesFloor &&
|
|
1029
|
+
requestClamp(projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true), view).cap <= asIsCheck.cap) {
|
|
1030
|
+
clips.clear();
|
|
1031
|
+
for (const [id, clip] of previousClips)
|
|
1032
|
+
clips.set(id, clip);
|
|
1033
|
+
const { room, hardLine } = limits(view);
|
|
1034
|
+
thinkingRetryPending = true;
|
|
1035
|
+
return { entries: [...entries, { type: "custom", customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
|
|
1036
|
+
stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: false,
|
|
1037
|
+
piEstimateTokens: asIsCheck.estimate, piCap: asIsCheck.cap, room, hardLine, buildId: BUILD_ID } }], continue: true };
|
|
1038
|
+
}
|
|
1039
|
+
if (decision.kind !== "cut")
|
|
1040
|
+
throw new CapacityError(clampMessage);
|
|
1041
|
+
const messages = projectedRequest(decision.plan.messages, view, hostInput, event.context.pendingMessages, ctx.getSystemPrompt(), pi.getActiveTools(), true);
|
|
1042
|
+
if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
|
|
1043
|
+
throw promptRefusal();
|
|
1044
|
+
const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
|
|
1045
|
+
const tokens = system + conversationTokens(messages), check = requestClamp(messages, view);
|
|
1046
|
+
const { response, room, hardLine } = limits(view);
|
|
1047
|
+
if (!fits(tokens, hardLine, view) || check.cap === undefined || !check.leavesFloor)
|
|
1048
|
+
throw new CapacityError(clampMessage);
|
|
1049
|
+
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) });
|
|
1050
|
+
thinkingRetryPending = true;
|
|
1051
|
+
pendingBoundaryCommit = true;
|
|
1052
|
+
return { entries: [...entries, { type: "compaction", summary: compaction.summary, firstKeptEntryId: compaction.firstKeptEntryId || null, details: compaction.details },
|
|
1053
|
+
{ type: "custom", customType: "pi-tuck-capacity", data: { kind: "retry", stoppedReplyEntryId: event.messageEntryId,
|
|
1054
|
+
stoppedOutputTokens: event.message.role === "assistant" ? event.message.usage?.output ?? 0 : 0, cut: true,
|
|
1055
|
+
estimatedTokens: corrected(tokens, view), piEstimateTokens: check.estimate, piCap: check.cap, room, hardLine, buildId: BUILD_ID } }], continue: true };
|
|
1056
|
+
};
|
|
879
1057
|
pi.on("turn_end", (event, ctx) => {
|
|
880
1058
|
try {
|
|
881
1059
|
if (!active() || !admittedRun)
|
|
882
1060
|
return;
|
|
1061
|
+
if (thinkingOnlyLength(event.message) && !failure && !ctx.signal?.aborted && settingsFor().getCompactionSettings(ctx.model).enabled)
|
|
1062
|
+
return retryThinking(event, ctx);
|
|
1063
|
+
thinkingRetryPending = false;
|
|
883
1064
|
const decide = event.outcome === "completed" && !ctx.signal?.aborted && event.context.canContinue && event.toolResults.length > 0 &&
|
|
884
1065
|
settingsFor().getCompactionSettings(ctx.model).enabled;
|
|
885
1066
|
if (!promptCut && !decide)
|
|
@@ -992,16 +1173,15 @@ export async function installCompaction(pi, options = {}) {
|
|
|
992
1173
|
const tokens = system + conversationTokens(messages);
|
|
993
1174
|
observeBudget(tokens, ctx);
|
|
994
1175
|
estimatedTokens = corrected(tokens, ctx);
|
|
995
|
-
const { room, hardLine
|
|
1176
|
+
const { room, hardLine } = limits(ctx);
|
|
996
1177
|
if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
|
|
997
1178
|
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");
|
|
998
1179
|
if (!fits(tokens, hardLine, ctx))
|
|
999
1180
|
throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
|
|
1000
1181
|
// Pi's request estimator and its compaction estimator need not agree. The hard line is
|
|
1001
1182
|
// necessary; above the room, also require the real request clamp to preserve the floor.
|
|
1002
|
-
if (!options.requestReserve && estimatedTokens > room && piClamp &&
|
|
1003
|
-
|
|
1004
|
-
throw new CapacityError("Pi's request clamp leaves less than the reply floor");
|
|
1183
|
+
if (!options.requestReserve && estimatedTokens > room && piClamp && !requestClamp(messages, ctx).leavesFloor)
|
|
1184
|
+
throw new CapacityError(clampMessage);
|
|
1005
1185
|
if (estimatedTokens > room)
|
|
1006
1186
|
receipt("above-room", "Request exceeds the room and fits the hard line", ctx);
|
|
1007
1187
|
if (recorder.enabled && !recorder.waitingForCut)
|