pi-pier 0.4.1 → 0.4.2
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/README.md +1 -2
- package/package.json +1 -1
- package/schemas/efficiency-config.schema.json +6 -0
- package/src/ask-dialog.ts +9 -2
- package/src/ask-user.ts +142 -74
- package/src/compact-coordinator.ts +204 -33
- package/src/compact-economics-core.ts +62 -29
- package/src/config-catalog-core.ts +1 -0
- package/src/delegation-core.ts +1 -1
- package/src/efficiency-config-core.ts +16 -2
- package/src/efficiency-store.ts +42 -5
- package/src/herdr-client.ts +15 -5
- package/src/index-pipe.ts +0 -8
- package/src/index.ts +69 -85
- package/src/jev-client.ts +32 -7
- package/src/jev-core.ts +70 -33
- package/src/observation-core.ts +13 -1
- package/src/pipe-channel.ts +0 -8
- package/src/plugins/heat-plan.ts +18 -3
- package/src/plugins/observation.ts +118 -46
- package/src/plugins/subagent.ts +53 -40
- package/src/plugins/terminal.ts +192 -47
- package/src/plugins/todo.ts +43 -21
- package/src/reducer-invoker.ts +27 -13
- package/src/renderers.ts +1 -13
- package/src/roles/worker-default.json +3 -3
- package/src/settle-wake-core.ts +8 -2
- package/src/subagent-core.ts +41 -3
- package/src/subagent-poller.ts +52 -18
- package/src/subagent-session.ts +67 -4
- package/src/subagent-spawn.ts +6 -5
- package/src/terminal-core.ts +111 -17
- package/src/todo-core.ts +47 -2
- package/src/todo-read-hook.ts +31 -17
- package/src/todo-reminder-core.ts +9 -5
- package/src/todo-window.ts +8 -4
- package/src/todos-service.ts +12 -0
- package/src/vocab.ts +7 -1
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
* compaction, then continues the task. State persists to session entries (branch rebuilds restore it).
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
7
|
+
import type { CompactionResult, ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
8
8
|
import { decideCompaction, DEFAULT_COMPACTION_ECONOMICS, estimateRemainingRequests, MAX_BOUNDARY_SAMPLES, nativeCompactionFeasible, resolveCacheRatioFromCost, type CompactionDecision } from './compact-economics-core.ts';
|
|
9
9
|
import type { TodoItem } from './todo-core.ts';
|
|
10
10
|
import type { TodoCompletionSource } from './todos-service.ts';
|
|
11
|
-
import type
|
|
11
|
+
import { SUMMARY_THINKING_LEVELS, type OnlineContextCompactConfig } from './efficiency-config-core.ts';
|
|
12
12
|
import { appendEfficiencyLog, efficiencyLogPath, resolveSessionRoot } from './efficiency-store.ts';
|
|
13
13
|
import { swallow } from './swallow.ts';
|
|
14
14
|
|
|
@@ -26,6 +26,20 @@ export const COMPACTION_INFLIGHT_TYPE = 'pi-herdr.compaction-inflight';
|
|
|
26
26
|
export const COMPACTION_SETTLED_TYPE = 'pi-herdr.compaction-settled';
|
|
27
27
|
const DEFAULT_KEEP_RECENT_TOKENS = 20_000;
|
|
28
28
|
const DEFAULT_MEMO_TOKENS = 1_000;
|
|
29
|
+
/**
|
|
30
|
+
* A non-compacting decision is revisited after this many provider requests even without a fresh todo
|
|
31
|
+
* boundary: an empty or stale todo list must not block OCC until the session ends (observed: 25/43
|
|
32
|
+
* decisions deferred at zero remaining boundaries, with a median of 45 further requests).
|
|
33
|
+
*/
|
|
34
|
+
export const DEFERRED_REEVALUATION_REQUESTS = 10;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Prefix-cache hit evidence is smoothed toward this many fully cached prefix tokens, so a single
|
|
38
|
+
* early miss cannot multiply the expected saving (~15 requests of a 60k context).
|
|
39
|
+
*/
|
|
40
|
+
export const CACHE_HIT_PRIOR_TOKENS = 1_000_000;
|
|
41
|
+
/** Past this many observed prefix tokens both sums halve, so the rate tracks recent provider behavior. */
|
|
42
|
+
export const CACHE_HIT_WINDOW_TOKENS = 20_000_000;
|
|
29
43
|
|
|
30
44
|
/** Fields persisted in the state marker; `version: 1` gates restores. */
|
|
31
45
|
interface CoordinatorState {
|
|
@@ -39,7 +53,18 @@ interface CoordinatorState {
|
|
|
39
53
|
positiveContextDeltaCount: number;
|
|
40
54
|
lastContextTokens: number | null;
|
|
41
55
|
currentBoundaryRequestCount: number;
|
|
56
|
+
/** Request count of the last non-compacting decision; -1 when no re-evaluation is scheduled. */
|
|
57
|
+
lastEvaluationRequestCount: number;
|
|
58
|
+
/**
|
|
59
|
+
* Context tokens pi estimated right after the last compaction (0 = unknown). Used as the retained
|
|
60
|
+
* floor for archive savings: fixed system-prompt/keepRecent arithmetic excludes tool schemas.
|
|
61
|
+
*/
|
|
62
|
+
retainedTokensAfterCompaction: number;
|
|
42
63
|
consecutiveCompactionFailures: number;
|
|
64
|
+
/** Prompt tokens of the previous request, summed per observed request (the reusable prefix). */
|
|
65
|
+
cachePrefixTokens: number;
|
|
66
|
+
/** Of those, tokens the provider served from cache. */
|
|
67
|
+
cacheReadTokens: number;
|
|
43
68
|
/** P1-3: turn-end compact decisions to skip after a failure (each abort costs an in-flight turn). */
|
|
44
69
|
compactBackoffTurnEnds: number;
|
|
45
70
|
}
|
|
@@ -52,7 +77,11 @@ const NUMERIC_STATE_FIELDS = [
|
|
|
52
77
|
'positiveContextDeltaTotal',
|
|
53
78
|
'positiveContextDeltaCount',
|
|
54
79
|
'currentBoundaryRequestCount',
|
|
80
|
+
'lastEvaluationRequestCount',
|
|
81
|
+
'retainedTokensAfterCompaction',
|
|
55
82
|
'consecutiveCompactionFailures',
|
|
83
|
+
'cachePrefixTokens',
|
|
84
|
+
'cacheReadTokens',
|
|
56
85
|
'compactBackoffTurnEnds',
|
|
57
86
|
] as const satisfies readonly (keyof CoordinatorState)[];
|
|
58
87
|
|
|
@@ -68,7 +97,11 @@ function initialCoordinatorState(): CoordinatorState {
|
|
|
68
97
|
positiveContextDeltaCount: 0,
|
|
69
98
|
lastContextTokens: null,
|
|
70
99
|
currentBoundaryRequestCount: 0,
|
|
100
|
+
lastEvaluationRequestCount: -1,
|
|
101
|
+
retainedTokensAfterCompaction: 0,
|
|
71
102
|
consecutiveCompactionFailures: 0,
|
|
103
|
+
cachePrefixTokens: 0,
|
|
104
|
+
cacheReadTokens: 0,
|
|
72
105
|
compactBackoffTurnEnds: 0,
|
|
73
106
|
};
|
|
74
107
|
}
|
|
@@ -98,10 +131,23 @@ function averageContextIncrement(state: CoordinatorState): number | null {
|
|
|
98
131
|
: null;
|
|
99
132
|
}
|
|
100
133
|
|
|
134
|
+
export function prefixCacheHitRate(state: CoordinatorState): number {
|
|
135
|
+
return (state.cacheReadTokens + CACHE_HIT_PRIOR_TOKENS) / (state.cachePrefixTokens + CACHE_HIT_PRIOR_TOKENS);
|
|
136
|
+
}
|
|
137
|
+
|
|
101
138
|
function remainingTodos(todos: readonly TodoItem[]): readonly TodoItem[] {
|
|
102
139
|
return todos.filter((it) => it.status !== 'completed' && it.status !== 'abandoned');
|
|
103
140
|
}
|
|
104
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Todos that pace the remaining-work horizon. Blocked items wait on something outside the agent's
|
|
144
|
+
* control and may never become actionable, so they must not inflate the horizon (observed: 7 blocked
|
|
145
|
+
* items were counted as seven remaining boundaries).
|
|
146
|
+
*/
|
|
147
|
+
export function horizonTodos(todos: readonly TodoItem[]): readonly TodoItem[] {
|
|
148
|
+
return remainingTodos(todos).filter((it) => it.status !== 'blocked');
|
|
149
|
+
}
|
|
150
|
+
|
|
105
151
|
/** P1-3: bound the payload — a huge open-task list inflates the summary request itself. */
|
|
106
152
|
function compactionInstructions(todos: readonly TodoItem[]): string {
|
|
107
153
|
const remaining = remainingTodos(todos);
|
|
@@ -122,6 +168,10 @@ export class CompactCoordinator {
|
|
|
122
168
|
intentionalAbort = false;
|
|
123
169
|
selectedCompaction: CompactionDecision | null = null;
|
|
124
170
|
pendingBoundaryCompleted = false;
|
|
171
|
+
/** Last carried-debt value written to the session; 0 on a fresh state (nothing to persist). */
|
|
172
|
+
private persistedDebtTokens = 0;
|
|
173
|
+
/** Prompt size of the previous request; 0 = no reusable prefix (session start, after compaction). */
|
|
174
|
+
private lastPromptTokens = 0;
|
|
125
175
|
|
|
126
176
|
recordBoundaryCompleted(count: number, source?: TodoCompletionSource): void {
|
|
127
177
|
// Only model tool calls count, and the sample is the requests spent reaching this boundary.
|
|
@@ -140,6 +190,15 @@ export class CompactCoordinator {
|
|
|
140
190
|
|
|
141
191
|
onBeforeProviderRequest(contextTokens: number): void {
|
|
142
192
|
this.state.currentBoundaryRequestCount++;
|
|
193
|
+
// Fallback calibration: when pi omitted estimatedTokensAfter, the first request after a
|
|
194
|
+
// compaction measures the retained context (summary + kept turns + fixed overhead) directly.
|
|
195
|
+
if (
|
|
196
|
+
this.state.retainedTokensAfterCompaction === 0 &&
|
|
197
|
+
this.state.priorCompactionCount > 0 &&
|
|
198
|
+
contextTokens > 0
|
|
199
|
+
) {
|
|
200
|
+
this.state.retainedTokensAfterCompaction = contextTokens;
|
|
201
|
+
}
|
|
143
202
|
if (this.state.carriedDebtTokens > 0 && this.state.cacheDebtRepaymentTokens > 0) {
|
|
144
203
|
this.state.carriedDebtTokens = Math.max(
|
|
145
204
|
0,
|
|
@@ -154,6 +213,25 @@ export class CompactCoordinator {
|
|
|
154
213
|
this.state.lastContextTokens = contextTokens;
|
|
155
214
|
}
|
|
156
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Measures how much of the previous request's prompt the provider served from cache. Fed from each
|
|
218
|
+
* assistant message's `usage`; a request with no usage (error, abort) measures nothing.
|
|
219
|
+
*/
|
|
220
|
+
onAssistantUsage(usage: { input?: number; cacheRead?: number; cacheWrite?: number } | undefined): void {
|
|
221
|
+
const cacheRead = usage?.cacheRead ?? 0;
|
|
222
|
+
const promptTokens = (usage?.input ?? 0) + cacheRead + (usage?.cacheWrite ?? 0);
|
|
223
|
+
if (promptTokens <= 0) return;
|
|
224
|
+
if (this.lastPromptTokens > 0) {
|
|
225
|
+
this.state.cachePrefixTokens += this.lastPromptTokens;
|
|
226
|
+
this.state.cacheReadTokens += Math.min(cacheRead, this.lastPromptTokens);
|
|
227
|
+
if (this.state.cachePrefixTokens > CACHE_HIT_WINDOW_TOKENS) {
|
|
228
|
+
this.state.cachePrefixTokens /= 2;
|
|
229
|
+
this.state.cacheReadTokens /= 2;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
this.lastPromptTokens = promptTokens;
|
|
233
|
+
}
|
|
234
|
+
|
|
157
235
|
onInput(event: { text?: string; source?: string; streamingBehavior?: string }): void {
|
|
158
236
|
// Ignore messages dispatched by extensions (D96 reminders, pipe injections, followUps).
|
|
159
237
|
if (event.source === 'extension' || event.text?.startsWith('CORRECTION:')) return;
|
|
@@ -167,6 +245,7 @@ export class CompactCoordinator {
|
|
|
167
245
|
this.selectedCompaction = null;
|
|
168
246
|
this.intentionalAbort = false;
|
|
169
247
|
this.pendingBoundaryCompleted = false;
|
|
248
|
+
this.state.lastEvaluationRequestCount = -1;
|
|
170
249
|
// completedBoundaryRequestCounts deliberately survives: it measures requests-per-boundary pacing,
|
|
171
250
|
// which holds across human turns — clearing it pinned expectedRemainingRequests at 1 every prompt.
|
|
172
251
|
this.state.carriedDebtTokens = 0;
|
|
@@ -177,16 +256,33 @@ export class CompactCoordinator {
|
|
|
177
256
|
ctx: ExtensionContext;
|
|
178
257
|
todos: readonly TodoItem[];
|
|
179
258
|
config: OnlineContextCompactConfig;
|
|
259
|
+
pi: ExtensionAPI;
|
|
260
|
+
/** stopReason of the assistant message that ended this turn ('toolUse' = work continues). */
|
|
261
|
+
lastStopReason: string | null;
|
|
180
262
|
cancelReminder?: () => void;
|
|
181
263
|
}): void {
|
|
264
|
+
if (opts.config.enabled) this.persistDebt(opts.pi);
|
|
265
|
+
|
|
182
266
|
const boundary = this.pendingBoundaryCompleted;
|
|
183
267
|
this.pendingBoundaryCompleted = false;
|
|
184
268
|
|
|
185
|
-
if (
|
|
269
|
+
if (this.selectedCompaction) return;
|
|
186
270
|
if (!opts.config.enabled) return;
|
|
187
271
|
// Safety guard: never abort while the user has a queued message.
|
|
188
272
|
if (opts.ctx.hasPendingMessages?.()) return;
|
|
189
273
|
|
|
274
|
+
// Revisit a non-compacting decision every N provider requests. Without this, a deferral made
|
|
275
|
+
// when the todo list is momentarily empty is never reassessed: the horizon stays collapsed at 1
|
|
276
|
+
// while the agent keeps working (evidence: 25/43 deferrals with zero open todos, median 45
|
|
277
|
+
// further requests, up to 249). Only a tool-call turn proves the work continues; a final 'stop'
|
|
278
|
+
// turn must not trigger a summary (and a spurious continuation) after the task is done.
|
|
279
|
+
const requestCount = this.state.currentBoundaryRequestCount;
|
|
280
|
+
const deferredDue =
|
|
281
|
+
opts.lastStopReason === 'toolUse' &&
|
|
282
|
+
this.state.lastEvaluationRequestCount >= 0 &&
|
|
283
|
+
requestCount - this.state.lastEvaluationRequestCount >= DEFERRED_REEVALUATION_REQUESTS;
|
|
284
|
+
if (!boundary && !deferredDue) return;
|
|
285
|
+
|
|
190
286
|
const usage = opts.ctx.getContextUsage?.();
|
|
191
287
|
const contextTokens =
|
|
192
288
|
typeof usage?.tokens === 'number' && usage.tokens > 0
|
|
@@ -196,15 +292,24 @@ export class CompactCoordinator {
|
|
|
196
292
|
// System prompt is re-sent verbatim, so it is never part of the compressible archive; ~4 chars/token.
|
|
197
293
|
const fixedTokens = Math.ceil(Buffer.byteLength(opts.ctx.getSystemPrompt?.() ?? '') / 4);
|
|
198
294
|
const keepRecentTokens = opts.config.keepRecentTokens ?? DEFAULT_KEEP_RECENT_TOKENS;
|
|
199
|
-
const
|
|
295
|
+
const memoTokens = DEFAULT_MEMO_TOKENS;
|
|
296
|
+
// pi's estimatedTokensAfter is the measured retained context (summary + kept recent turns + all
|
|
297
|
+
// fixed overhead, tool schemas included). Prefer it over the fixedTokens/keepRecent arithmetic,
|
|
298
|
+
// which ignores tool schemas and understates the retained floor (observed: savings overstated
|
|
299
|
+
// ~23%). The estimate already contains the memo, so rebuild the gross archive the model expects.
|
|
300
|
+
const retainedTokens = this.state.retainedTokensAfterCompaction;
|
|
301
|
+
const archiveTokens = retainedTokens > 0 && retainedTokens < contextTokens
|
|
302
|
+
? contextTokens - retainedTokens + memoTokens
|
|
303
|
+
: Math.max(0, contextTokens - fixedTokens - keepRecentTokens);
|
|
200
304
|
|
|
201
305
|
const decision = decideCompaction({
|
|
202
306
|
writeTokens: contextTokens,
|
|
203
307
|
archiveTokens,
|
|
204
|
-
memoTokens
|
|
308
|
+
memoTokens,
|
|
205
309
|
contextTokens,
|
|
206
310
|
completedBoundaryRequestCounts: this.state.completedBoundaryRequestCounts,
|
|
207
|
-
remainingBoundaries:
|
|
311
|
+
remainingBoundaries: horizonTodos(opts.todos).length,
|
|
312
|
+
observedRequestsSinceBoundary: requestCount,
|
|
208
313
|
averageContextTokenIncrement: averageContextIncrement(this.state),
|
|
209
314
|
contextWindowTokens,
|
|
210
315
|
priorCompactionCount: this.state.priorCompactionCount,
|
|
@@ -215,6 +320,7 @@ export class CompactCoordinator {
|
|
|
215
320
|
opts.ctx.model?.cost,
|
|
216
321
|
{ provider: opts.ctx.model?.provider, modelId: opts.ctx.model?.id },
|
|
217
322
|
),
|
|
323
|
+
prefixCacheHitRate: prefixCacheHitRate(this.state),
|
|
218
324
|
economics: {
|
|
219
325
|
...DEFAULT_COMPACTION_ECONOMICS,
|
|
220
326
|
firstCompactionRequestScale: opts.config.firstCompactionRequestScale,
|
|
@@ -223,6 +329,7 @@ export class CompactCoordinator {
|
|
|
223
329
|
});
|
|
224
330
|
|
|
225
331
|
if (!decision.compact) {
|
|
332
|
+
this.state.lastEvaluationRequestCount = requestCount;
|
|
226
333
|
if (opts.config.logEnabled) this.logDecision(opts.ctx, decision);
|
|
227
334
|
return;
|
|
228
335
|
}
|
|
@@ -232,6 +339,7 @@ export class CompactCoordinator {
|
|
|
232
339
|
// class is gone since pi 1.4.0 uncapped summary output; transient timeouts/aborts remain.)
|
|
233
340
|
if (this.state.compactBackoffTurnEnds > 0) {
|
|
234
341
|
this.state.compactBackoffTurnEnds--;
|
|
342
|
+
this.state.lastEvaluationRequestCount = requestCount;
|
|
235
343
|
if (opts.config.logEnabled) {
|
|
236
344
|
this.logDecision(opts.ctx, { ...decision, compact: false, reason: 'failure_backoff' });
|
|
237
345
|
}
|
|
@@ -240,12 +348,14 @@ export class CompactCoordinator {
|
|
|
240
348
|
|
|
241
349
|
const branch = opts.ctx.sessionManager?.getBranch?.() ?? [];
|
|
242
350
|
if (!nativeCompactionFeasible(branch, keepRecentTokens)) {
|
|
351
|
+
this.state.lastEvaluationRequestCount = requestCount;
|
|
243
352
|
if (opts.config.logEnabled) {
|
|
244
353
|
this.logDecision(opts.ctx, { ...decision, compact: false, reason: 'native_not_compactable' });
|
|
245
354
|
}
|
|
246
355
|
return;
|
|
247
356
|
}
|
|
248
357
|
|
|
358
|
+
this.state.lastEvaluationRequestCount = -1;
|
|
249
359
|
this.selectedCompaction = decision;
|
|
250
360
|
this.intentionalAbort = true;
|
|
251
361
|
opts.cancelReminder?.();
|
|
@@ -260,7 +370,15 @@ export class CompactCoordinator {
|
|
|
260
370
|
cancelReminder?: () => void;
|
|
261
371
|
onBeforeCompact?: (sessionRoot: string, ctx: ExtensionContext) => Promise<void>;
|
|
262
372
|
}): Promise<void> {
|
|
263
|
-
if (!this.selectedCompaction
|
|
373
|
+
if (!this.selectedCompaction) return;
|
|
374
|
+
if (!opts.ctx.isIdle()) {
|
|
375
|
+
// The settlement is not consumable (another run already started): drop the stale selection.
|
|
376
|
+
// Keeping it would latch OCC off every later turn and suppress settle-wake notices until the
|
|
377
|
+
// next human input; a fresh decision re-selects when the economics still hold.
|
|
378
|
+
this.selectedCompaction = null;
|
|
379
|
+
this.intentionalAbort = false;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
264
382
|
|
|
265
383
|
// Announce the compaction window before the summary request starts: the supervising master
|
|
266
384
|
// polls this file, and its observation window is shorter than a large-context compaction.
|
|
@@ -282,41 +400,75 @@ export class CompactCoordinator {
|
|
|
282
400
|
}
|
|
283
401
|
}
|
|
284
402
|
|
|
403
|
+
// pi summarizes at the session's thinking level; at `max`, 67% of summary output was reasoning
|
|
404
|
+
// and one compaction took 144 s. Cap it for the summary only, and always restore the session's.
|
|
405
|
+
const sessionThinking = opts.pi.getThinkingLevel();
|
|
406
|
+
const cap = opts.config.summaryThinkingLevel;
|
|
407
|
+
const lowered = cap !== 'inherit'
|
|
408
|
+
&& SUMMARY_THINKING_LEVELS.indexOf(cap) < SUMMARY_THINKING_LEVELS.indexOf(sessionThinking);
|
|
409
|
+
if (lowered) opts.pi.setThinkingLevel(cap);
|
|
285
410
|
const startMs = Date.now();
|
|
286
411
|
const settled = Promise.withResolvers<void>();
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
412
|
+
try {
|
|
413
|
+
opts.ctx.compact({
|
|
414
|
+
customInstructions: compactionInstructions(opts.todos),
|
|
415
|
+
onComplete: (compaction) => {
|
|
416
|
+
try {
|
|
417
|
+
this.finishCompaction(opts, decision, startMs, compaction);
|
|
418
|
+
} finally {
|
|
419
|
+
settled.resolve();
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
onError: (error) => {
|
|
423
|
+
try {
|
|
424
|
+
this.failCompaction(opts, decision, startMs, error);
|
|
425
|
+
} finally {
|
|
426
|
+
settled.resolve();
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
await settled.promise;
|
|
431
|
+
} finally {
|
|
432
|
+
if (lowered) opts.pi.setThinkingLevel(sessionThinking);
|
|
433
|
+
}
|
|
305
434
|
}
|
|
306
435
|
|
|
307
436
|
private finishCompaction(
|
|
308
437
|
opts: { ctx: ExtensionContext; pi: ExtensionAPI; config: OnlineContextCompactConfig },
|
|
309
438
|
decision: CompactionDecision,
|
|
310
439
|
startMs: number,
|
|
311
|
-
compaction:
|
|
440
|
+
compaction: CompactionResult | undefined,
|
|
312
441
|
): void {
|
|
442
|
+
const usage = compaction?.usage;
|
|
443
|
+
// Debt is the summary request's real extra spend, converted to cache-read-token units so the
|
|
444
|
+
// ledger stays in the same unit as savingTokens. Fall back to the modeled rewrite surcharge when
|
|
445
|
+
// pi reports no usage or the model has no cache-read price.
|
|
446
|
+
const cacheReadPricePerToken = (opts.ctx.model?.cost?.cacheRead ?? 0) / 1_000_000;
|
|
447
|
+
const measuredDebtTokens =
|
|
448
|
+
usage !== undefined && cacheReadPricePerToken > 0
|
|
449
|
+
? usage.cost.total / cacheReadPricePerToken
|
|
450
|
+
: null;
|
|
451
|
+
const summaryDebtTokens =
|
|
452
|
+
measuredDebtTokens ?? decision.writeTokens * (decision.incrementalCacheCostRatio ?? 0);
|
|
453
|
+
// Unpaid debt from the previous compaction survives: repayment happens per subsequent request,
|
|
454
|
+
// so a new compaction must not write off what the ledger has not collected yet.
|
|
455
|
+
const unpaidDebtTokens = this.state.carriedDebtTokens;
|
|
456
|
+
|
|
313
457
|
this.state.priorCompactionCount++;
|
|
314
|
-
this.state.carriedDebtTokens =
|
|
315
|
-
this.state.cacheDebtRepaymentTokens =
|
|
458
|
+
this.state.carriedDebtTokens = unpaidDebtTokens + summaryDebtTokens;
|
|
459
|
+
this.state.cacheDebtRepaymentTokens = decision.savingTokensPerRequest;
|
|
460
|
+
// The summary replaced the prefix: the next request is a cold rewrite the debt already prices.
|
|
461
|
+
this.lastPromptTokens = 0;
|
|
462
|
+
this.state.retainedTokensAfterCompaction =
|
|
463
|
+
typeof compaction?.estimatedTokensAfter === 'number' ? compaction.estimatedTokensAfter : 0;
|
|
464
|
+
// The pacing window restarts after a compaction: requests before it measured the old context.
|
|
465
|
+
this.state.currentBoundaryRequestCount = 0;
|
|
466
|
+
this.state.lastEvaluationRequestCount = -1;
|
|
316
467
|
this.state.epoch++;
|
|
317
468
|
this.state.consecutiveCompactionFailures = 0;
|
|
318
469
|
this.state.compactBackoffTurnEnds = 0;
|
|
319
470
|
this.appendMarker(opts.pi, COMPACT_STATE_CUSTOM_TYPE, this.state);
|
|
471
|
+
this.persistedDebtTokens = this.state.carriedDebtTokens;
|
|
320
472
|
|
|
321
473
|
if (opts.config.logEnabled) {
|
|
322
474
|
this.logCompact(opts.ctx, {
|
|
@@ -327,7 +479,9 @@ export class CompactCoordinator {
|
|
|
327
479
|
breakevenRequests: decision.breakevenRequests,
|
|
328
480
|
expectedRemainingRequests: decision.expectedRemainingRequests,
|
|
329
481
|
carriedDebtTokens: this.state.carriedDebtTokens,
|
|
330
|
-
summaryTokens:
|
|
482
|
+
summaryTokens: usage !== undefined
|
|
483
|
+
? usage.output
|
|
484
|
+
: Math.ceil((compaction?.summary.length ?? 0) / 4),
|
|
331
485
|
priorCompactionCount: this.state.priorCompactionCount,
|
|
332
486
|
durationMs: Date.now() - startMs,
|
|
333
487
|
});
|
|
@@ -364,6 +518,12 @@ export class CompactCoordinator {
|
|
|
364
518
|
outcome: cancelled ? 'cancelled' : 'failed',
|
|
365
519
|
at: Date.now(),
|
|
366
520
|
});
|
|
521
|
+
// P1-3: record the failure and back off exponentially (cap 4 skipped decisions). Increment
|
|
522
|
+
// before logging so the record carries this failure's ordinal instead of the previous count.
|
|
523
|
+
if (!cancelled) {
|
|
524
|
+
this.state.consecutiveCompactionFailures++;
|
|
525
|
+
this.state.compactBackoffTurnEnds = Math.min(2 ** this.state.consecutiveCompactionFailures, 4);
|
|
526
|
+
}
|
|
367
527
|
if (opts.config.logEnabled) {
|
|
368
528
|
this.logCompact(opts.ctx, {
|
|
369
529
|
event: 'compaction-failed',
|
|
@@ -376,12 +536,10 @@ export class CompactCoordinator {
|
|
|
376
536
|
}
|
|
377
537
|
if (cancelled) return;
|
|
378
538
|
|
|
379
|
-
// P1-3: record the failure and back off exponentially (cap 4 skipped decisions).
|
|
380
|
-
this.state.consecutiveCompactionFailures++;
|
|
381
|
-
this.state.compactBackoffTurnEnds = Math.min(2 ** this.state.consecutiveCompactionFailures, 4);
|
|
382
539
|
// Persist the bumped counters: state is restored from this marker, so a restart without it
|
|
383
540
|
// would reset the P1-3 backoff window and retry the doomed compaction immediately.
|
|
384
541
|
this.appendMarker(opts.pi, COMPACT_STATE_CUSTOM_TYPE, this.state);
|
|
542
|
+
this.persistedDebtTokens = this.state.carriedDebtTokens;
|
|
385
543
|
|
|
386
544
|
// OCC aborted the turn on purpose, so a failed compaction would leave the session parked on an
|
|
387
545
|
// aborted message; the notice must be visible rather than a silent failure.
|
|
@@ -406,6 +564,16 @@ export class CompactCoordinator {
|
|
|
406
564
|
}
|
|
407
565
|
}
|
|
408
566
|
|
|
567
|
+
/**
|
|
568
|
+
* Persist repayment progress when the debt moves. restoreCoordinatorState reads the last state
|
|
569
|
+
* marker, so an in-memory-only ledger resurrects already-repaid debt after a resume.
|
|
570
|
+
*/
|
|
571
|
+
private persistDebt(pi: ExtensionAPI): void {
|
|
572
|
+
if (this.persistedDebtTokens === this.state.carriedDebtTokens) return;
|
|
573
|
+
this.appendMarker(pi, COMPACT_STATE_CUSTOM_TYPE, this.state);
|
|
574
|
+
this.persistedDebtTokens = this.state.carriedDebtTokens;
|
|
575
|
+
}
|
|
576
|
+
|
|
409
577
|
/** Resume the task after compaction; must never throw into the detached settled handler. */
|
|
410
578
|
private sendContinuation(pi: ExtensionAPI, content: string, display: boolean): void {
|
|
411
579
|
try {
|
|
@@ -416,6 +584,7 @@ export class CompactCoordinator {
|
|
|
416
584
|
|
|
417
585
|
rebuildFromBranch(entries: readonly unknown[]): void {
|
|
418
586
|
this.state = restoreCoordinatorState(entries);
|
|
587
|
+
this.persistedDebtTokens = this.state.carriedDebtTokens;
|
|
419
588
|
this.selectedCompaction = null;
|
|
420
589
|
this.compactionInFlight = false;
|
|
421
590
|
this.intentionalAbort = false;
|
|
@@ -429,10 +598,10 @@ export class CompactCoordinator {
|
|
|
429
598
|
completedBoundaryRequestCounts: counts,
|
|
430
599
|
remainingBoundaries,
|
|
431
600
|
scale: DEFAULT_COMPACTION_ECONOMICS.remainingRequestScale,
|
|
432
|
-
standardDeviationK: DEFAULT_COMPACTION_ECONOMICS.remainingRequestStddevK,
|
|
433
601
|
contextTokens: this.state.lastContextTokens ?? 0,
|
|
434
602
|
contextWindowTokens,
|
|
435
603
|
averageContextTokenIncrement: averageContextIncrement(this.state),
|
|
604
|
+
observedRequestsSinceBoundary: this.state.currentBoundaryRequestCount,
|
|
436
605
|
});
|
|
437
606
|
return Math.max(1, est.expectedRemainingRequests);
|
|
438
607
|
}
|
|
@@ -464,6 +633,8 @@ export class CompactCoordinator {
|
|
|
464
633
|
// Without resolvedRatio/model a cache_ratio_unavailable run is undiagnosable from logs alone.
|
|
465
634
|
resolvedRatio: decision.cacheWriteReadRatio,
|
|
466
635
|
incrementalCacheCostRatio: decision.incrementalCacheCostRatio,
|
|
636
|
+
prefixCacheHitRate: decision.prefixCacheHitRate,
|
|
637
|
+
savingTokensPerRequest: decision.savingTokensPerRequest,
|
|
467
638
|
remainingBoundaries: decision.remainingBoundaries,
|
|
468
639
|
model: ctx.model?.id ?? undefined,
|
|
469
640
|
provider: ctx.model?.provider ?? undefined,
|
|
@@ -8,7 +8,6 @@ import { findCutPoint, sessionEntryToContextMessages, type SessionEntry } from '
|
|
|
8
8
|
|
|
9
9
|
interface CompactionEconomics {
|
|
10
10
|
readonly remainingRequestScale: number;
|
|
11
|
-
readonly remainingRequestStddevK: number;
|
|
12
11
|
readonly windowReserveTokens: number;
|
|
13
12
|
readonly firstCompactionRequestScale: number;
|
|
14
13
|
readonly subsequentCompactionMargin: number;
|
|
@@ -16,7 +15,6 @@ interface CompactionEconomics {
|
|
|
16
15
|
|
|
17
16
|
export const DEFAULT_COMPACTION_ECONOMICS: CompactionEconomics = Object.freeze({
|
|
18
17
|
remainingRequestScale: 1.0,
|
|
19
|
-
remainingRequestStddevK: 0.0,
|
|
20
18
|
windowReserveTokens: 16_384,
|
|
21
19
|
firstCompactionRequestScale: 2.0,
|
|
22
20
|
subsequentCompactionMargin: 1.5,
|
|
@@ -51,8 +49,7 @@ type CompactionReason =
|
|
|
51
49
|
|
|
52
50
|
interface RequestHorizonEstimate {
|
|
53
51
|
readonly requestsPerBoundaryMean: number;
|
|
54
|
-
readonly
|
|
55
|
-
readonly unboundedExpectedRemainingRequests: number;
|
|
52
|
+
readonly requestsPerBoundaryEstimate: number;
|
|
56
53
|
readonly windowRequestUpperBound: number | null;
|
|
57
54
|
readonly expectedRemainingRequests: number;
|
|
58
55
|
}
|
|
@@ -68,6 +65,13 @@ export interface CompactionDecision {
|
|
|
68
65
|
readonly remainingBoundaries: number;
|
|
69
66
|
readonly cacheWriteReadRatio: number | null;
|
|
70
67
|
readonly incrementalCacheCostRatio: number | null;
|
|
68
|
+
/** Observed fraction of the reusable prefix served from cache (1 = every request a hit). */
|
|
69
|
+
readonly prefixCacheHitRate: number;
|
|
70
|
+
/**
|
|
71
|
+
* Expected per-request saving in cache-read-token units: a missed request bills the archived
|
|
72
|
+
* prefix at the write/input price, so it saves `cacheWriteReadRatio` times more than a hit.
|
|
73
|
+
*/
|
|
74
|
+
readonly savingTokensPerRequest: number;
|
|
71
75
|
readonly priorCompactionCount: number;
|
|
72
76
|
readonly carriedDebtTokens: number;
|
|
73
77
|
readonly cacheDebtRepaymentTokens: number;
|
|
@@ -75,8 +79,19 @@ export interface CompactionDecision {
|
|
|
75
79
|
readonly reason: CompactionReason;
|
|
76
80
|
}
|
|
77
81
|
|
|
78
|
-
|
|
79
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Sparse boundary samples are noisy: a single 127-request boundary extrapolated over ten todos once
|
|
84
|
+
* predicted 509 remaining requests where 16 were left. Shrink each mean toward a small prior (a todo
|
|
85
|
+
* completion typically lands after a handful of model/tool steps) so one or two samples cannot
|
|
86
|
+
* dominate; after a few samples the prior fades out.
|
|
87
|
+
*/
|
|
88
|
+
const SAMPLE_PRIOR_REQUESTS_PER_BOUNDARY = 5;
|
|
89
|
+
const SAMPLE_PRIOR_WEIGHT = 2;
|
|
90
|
+
/**
|
|
91
|
+
* Hard ceiling on any projected horizon. Compaction must pay for itself, and no observed boundary is
|
|
92
|
+
* evidence for an arbitrarily long remaining stretch.
|
|
93
|
+
*/
|
|
94
|
+
export const MAX_HORIZON_REQUESTS = 100;
|
|
80
95
|
/** Retained boundary-request sample cap so the mean tracks recent pacing. */
|
|
81
96
|
export const MAX_BOUNDARY_SAMPLES = 16;
|
|
82
97
|
|
|
@@ -84,26 +99,31 @@ export function estimateRemainingRequests(input: {
|
|
|
84
99
|
readonly completedBoundaryRequestCounts: readonly number[];
|
|
85
100
|
readonly remainingBoundaries: number;
|
|
86
101
|
readonly scale: number;
|
|
87
|
-
readonly standardDeviationK: number;
|
|
88
102
|
readonly contextTokens: number;
|
|
89
103
|
readonly contextWindowTokens: number | null;
|
|
90
104
|
readonly averageContextTokenIncrement: number | null;
|
|
105
|
+
/**
|
|
106
|
+
* Requests already spent since the last completed boundary or compaction. While work is visibly
|
|
107
|
+
* continuing the horizon must not collapse to 1 just because the todo list is momentarily empty.
|
|
108
|
+
*/
|
|
109
|
+
readonly observedRequestsSinceBoundary?: number;
|
|
91
110
|
}): RequestHorizonEstimate {
|
|
92
111
|
const counts = input.completedBoundaryRequestCounts;
|
|
93
112
|
const mean = counts.reduce((total, count) => total + count, 0) / Math.max(1, counts.length);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const variance = counts.reduce((total, count) => total + (count - mean) ** 2, 0);
|
|
100
|
-
const deviation = Math.sqrt(variance / (counts.length - 1));
|
|
101
|
-
lowerBound = Math.max(0, mean - input.standardDeviationK * deviation);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
113
|
+
const estimatePerBoundary =
|
|
114
|
+
counts.length === 0
|
|
115
|
+
? 0
|
|
116
|
+
: (mean * counts.length + SAMPLE_PRIOR_REQUESTS_PER_BOUNDARY * SAMPLE_PRIOR_WEIGHT)
|
|
117
|
+
/ (counts.length + SAMPLE_PRIOR_WEIGHT);
|
|
104
118
|
|
|
105
|
-
const
|
|
106
|
-
|
|
119
|
+
const observedWorkFloor = Math.min(
|
|
120
|
+
MAX_HORIZON_REQUESTS,
|
|
121
|
+
Math.max(0, input.observedRequestsSinceBoundary ?? 0),
|
|
122
|
+
);
|
|
123
|
+
const requestEstimate = Math.max(
|
|
124
|
+
1 + Math.floor(estimatePerBoundary * Math.max(0, input.remainingBoundaries) * input.scale),
|
|
125
|
+
observedWorkFloor,
|
|
126
|
+
);
|
|
107
127
|
const windowRequestUpperBound =
|
|
108
128
|
input.contextWindowTokens === null ||
|
|
109
129
|
input.averageContextTokenIncrement === null ||
|
|
@@ -116,13 +136,13 @@ export function estimateRemainingRequests(input: {
|
|
|
116
136
|
|
|
117
137
|
return {
|
|
118
138
|
requestsPerBoundaryMean: mean,
|
|
119
|
-
|
|
120
|
-
unboundedExpectedRemainingRequests,
|
|
139
|
+
requestsPerBoundaryEstimate: estimatePerBoundary,
|
|
121
140
|
windowRequestUpperBound,
|
|
122
|
-
expectedRemainingRequests:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
141
|
+
expectedRemainingRequests: Math.min(
|
|
142
|
+
MAX_HORIZON_REQUESTS,
|
|
143
|
+
requestEstimate,
|
|
144
|
+
windowRequestUpperBound ?? Number.POSITIVE_INFINITY,
|
|
145
|
+
),
|
|
126
146
|
};
|
|
127
147
|
}
|
|
128
148
|
|
|
@@ -139,6 +159,10 @@ export function decideCompaction(input: {
|
|
|
139
159
|
readonly carriedDebtTokens: number;
|
|
140
160
|
readonly cacheDebtRepaymentTokens: number;
|
|
141
161
|
readonly cacheWriteReadRatio: number | null;
|
|
162
|
+
/** Observed prefix cache hit rate in [0, 1]; omitted = every request priced as a hit. */
|
|
163
|
+
readonly prefixCacheHitRate?: number;
|
|
164
|
+
/** Requests observed since the last boundary/compaction; a floor on the remaining horizon. */
|
|
165
|
+
readonly observedRequestsSinceBoundary?: number;
|
|
142
166
|
readonly economics?: CompactionEconomics;
|
|
143
167
|
}): CompactionDecision {
|
|
144
168
|
const economics = input.economics ?? DEFAULT_COMPACTION_ECONOMICS;
|
|
@@ -146,10 +170,10 @@ export function decideCompaction(input: {
|
|
|
146
170
|
completedBoundaryRequestCounts: input.completedBoundaryRequestCounts,
|
|
147
171
|
remainingBoundaries: input.remainingBoundaries,
|
|
148
172
|
scale: economics.remainingRequestScale,
|
|
149
|
-
standardDeviationK: economics.remainingRequestStddevK,
|
|
150
173
|
contextTokens: input.contextTokens,
|
|
151
174
|
contextWindowTokens: input.contextWindowTokens,
|
|
152
175
|
averageContextTokenIncrement: input.averageContextTokenIncrement,
|
|
176
|
+
observedRequestsSinceBoundary: input.observedRequestsSinceBoundary,
|
|
153
177
|
});
|
|
154
178
|
|
|
155
179
|
const savingTokens = input.archiveTokens - input.memoTokens;
|
|
@@ -159,12 +183,19 @@ export function decideCompaction(input: {
|
|
|
159
183
|
const incrementalWriteCost = incrementalCacheCostRatio === null
|
|
160
184
|
? null
|
|
161
185
|
: input.writeTokens * incrementalCacheCostRatio;
|
|
186
|
+
// Pricing every request as a cache hit undervalues compaction: 1.4% of requests miss the prefix
|
|
187
|
+
// cache (TTL expiry) and bill it at the input price — 50x cache-read on deepseek, ~1.7x the
|
|
188
|
+
// expected saving. The ratio never drops below 1, so the scaled saving never shrinks.
|
|
189
|
+
const prefixCacheHitRate = Math.min(1, Math.max(0, input.prefixCacheHitRate ?? 1));
|
|
190
|
+
const missPriceRatio = Math.max(1, input.cacheWriteReadRatio ?? 1);
|
|
191
|
+
const savingTokensPerRequest = Math.max(0, savingTokens)
|
|
192
|
+
* (prefixCacheHitRate + (1 - prefixCacheHitRate) * missPriceRatio);
|
|
162
193
|
|
|
163
194
|
const breakevenRequests = compressible && incrementalWriteCost !== null
|
|
164
|
-
? incrementalWriteCost /
|
|
195
|
+
? incrementalWriteCost / savingTokensPerRequest
|
|
165
196
|
: null;
|
|
166
197
|
const combinedBreakevenRequests = compressible && incrementalWriteCost !== null
|
|
167
|
-
? (input.carriedDebtTokens + incrementalWriteCost) /
|
|
198
|
+
? (input.carriedDebtTokens + incrementalWriteCost) / savingTokensPerRequest
|
|
168
199
|
: null;
|
|
169
200
|
|
|
170
201
|
const firstCompaction = input.priorCompactionCount === 0;
|
|
@@ -232,6 +263,8 @@ export function decideCompaction(input: {
|
|
|
232
263
|
remainingBoundaries: input.remainingBoundaries,
|
|
233
264
|
cacheWriteReadRatio: input.cacheWriteReadRatio,
|
|
234
265
|
incrementalCacheCostRatio,
|
|
266
|
+
prefixCacheHitRate,
|
|
267
|
+
savingTokensPerRequest,
|
|
235
268
|
priorCompactionCount: input.priorCompactionCount,
|
|
236
269
|
carriedDebtTokens: input.carriedDebtTokens,
|
|
237
270
|
cacheDebtRepaymentTokens: input.cacheDebtRepaymentTokens,
|
|
@@ -312,7 +345,7 @@ export function nativeCompactionFeasible(entries: readonly SessionEntry[], keepR
|
|
|
312
345
|
for (let index = path.length - 1; index >= 0; index--) {
|
|
313
346
|
const entry = path[index];
|
|
314
347
|
if (entry?.type !== 'compaction') continue;
|
|
315
|
-
const keptIndex = path.findIndex((item) => item.id ===
|
|
348
|
+
const keptIndex = path.findIndex((item) => item.id === entry.firstKeptEntryId);
|
|
316
349
|
startIndex = keptIndex >= 0 ? keptIndex : index + 1;
|
|
317
350
|
break;
|
|
318
351
|
}
|
|
@@ -79,6 +79,7 @@ const EFFICIENCY_KNOBS: readonly ConfigKnob[] = [
|
|
|
79
79
|
{ plane: 'efficiency', key: 'onlineContextCompact.firstCompactionRequestScale', kind: 'number', defaultValue: 2.0, min: 1.0, impact: 'First-compaction horizon relaxation', docRef: 'docs/efficiency-trial.md' },
|
|
80
80
|
{ plane: 'efficiency', key: 'onlineContextCompact.subsequentCompactionMargin', kind: 'number', defaultValue: 1.5, min: 1.0, impact: 'Safety margin required for later compactions', docRef: 'docs/efficiency-trial.md' },
|
|
81
81
|
{ plane: 'efficiency', key: 'onlineContextCompact.keepRecentTokens', kind: 'number', defaultValue: 20000, min: 1000, impact: 'Native compaction tail window; inherited from pi unless set here', docRef: 'docs/configuration.md' },
|
|
82
|
+
{ plane: 'efficiency', key: 'onlineContextCompact.summaryThinkingLevel', kind: 'enum', defaultValue: 'low', impact: 'Cap on the compaction summary thinking level (inherit | off | minimal | low | medium | high | xhigh | max); never raised above the session level', docRef: 'docs/efficiency-trial.md' },
|
|
82
83
|
{ plane: 'efficiency', key: 'observationPack.enabled', kind: 'boolean', defaultValue: false, envVar: 'PI_HERDR_OBS_PACK_ENABLE', impact: 'Project large tool outputs as placeholders (observationPack)', docRef: 'docs/efficiency-trial.md' },
|
|
83
84
|
{ plane: 'efficiency', key: 'observationPack.logEnabled', kind: 'boolean', defaultValue: false, envVar: 'PI_HERDR_OBS_PACK_LOG', impact: 'Write observation.jsonl packed/recall records', docRef: 'docs/efficiency-trial.md' },
|
|
84
85
|
{ plane: 'efficiency', key: 'observationPack.thresholdBytes', kind: 'number', defaultValue: 10240, min: 1024, impact: 'Minimum output size before packing applies', docRef: 'docs/efficiency-trial.md' },
|
package/src/delegation-core.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface DelegationContext {
|
|
|
26
26
|
readonly description: string;
|
|
27
27
|
/** Role manifest name active at spawn. */
|
|
28
28
|
readonly role: string;
|
|
29
|
-
/**
|
|
29
|
+
/** The master's pipe name — the worker's `todo_sync` push dials it directly (D49 reply-address
|
|
30
30
|
* semantics, precomputed because the worker cannot derive the master's session cwd). */
|
|
31
31
|
readonly parentPipe?: string;
|
|
32
32
|
}
|