Skip to content

Commit e086d10

Browse files
committed
fix(trigger): projection only counts reasoning as droppable when the clearing path can drop it (#274)
2 parents d8ce624 + 911f1b2 commit e086d10

4 files changed

Lines changed: 95 additions & 3 deletions

File tree

‎packages/pi-plugin/src/context-handler.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3842,6 +3842,9 @@ function maybeFireHistorian(args: {
38423842
}
38433843
}
38443844

3845+
// Pi's cleared thinking is safe to project for every provider: its
3846+
// serializers omit empty thinking blocks, unlike OpenCode's
3847+
// canonical-Anthropic-only empty-sentinel path.
38453848
const trigger = checkCompartmentTrigger(
38463849
db,
38473850
sessionId,
@@ -3859,6 +3862,7 @@ function maybeFireHistorian(args: {
38593862
return { messages, absoluteMessageCount: messages.length };
38603863
},
38613864
args.taggerFloor,
3865+
{ canClearReasoning: true },
38623866
);
38633867

38643868
if (!trigger.shouldFire) {

‎packages/plugin/src/hooks/magic-context/compartment-trigger.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,68 @@ describe("checkCompartmentTrigger", () => {
469469
expect(result).toEqual({ shouldFire: false });
470470
});
471471

472+
it("projects aged reasoning only when the provider can clear it from the wire", () => {
473+
// Match issue #274's shape: at 36.8% usage, 70% of tagged bytes are
474+
// aged reasoning. Counting it as reclaimable projects 11.0%, below the
475+
// 16.2% post-drop target; a provider that cannot clear it must instead
476+
// fire the historian at the real 36.8% pressure.
477+
useTempDataHome("compartment-trigger-unavailable-reasoning-");
478+
const sessionId = "ses-unavailable-reasoning";
479+
createOpenCodeDb(sessionId, [
480+
{ id: "m-1", role: "user", text: "a ".repeat(3500) },
481+
{ id: "m-2", role: "assistant", text: "done" },
482+
{ id: "m-3", role: "user", text: "b ".repeat(3500) },
483+
{ id: "m-4", role: "assistant", text: "done" },
484+
{ id: "m-5", role: "user", text: "c ".repeat(3500) },
485+
{ id: "m-6", role: "assistant", text: "done" },
486+
{ id: "m-7", role: "user", text: "protected tail 1" },
487+
{ id: "m-8", role: "user", text: "protected tail 2" },
488+
{ id: "m-9", role: "user", text: "protected tail 3" },
489+
{ id: "m-10", role: "user", text: "protected tail 4" },
490+
{ id: "m-11", role: "user", text: "protected tail 5" },
491+
]);
492+
const db = openDatabase();
493+
// Total tagged bytes = 1,000; tag 1's 700 reasoning bytes are old
494+
// enough for clearing because tag 51 establishes the age cutoff.
495+
insertTag(db, sessionId, "m-1", "message", 299, 1, 700);
496+
insertTag(db, sessionId, "m-11", "message", 1, 51);
497+
const runTrigger = (reasoningProjection: {
498+
providerID?: string;
499+
canClearReasoning?: boolean;
500+
}) =>
501+
checkCompartmentTrigger(
502+
db,
503+
sessionId,
504+
makeSessionMeta(sessionId, 36.8),
505+
{ percentage: 36.8, inputTokens: 736_000 },
506+
36.8,
507+
21.6,
508+
6500,
509+
50,
510+
undefined,
511+
undefined,
512+
undefined,
513+
undefined,
514+
undefined,
515+
reasoningProjection,
516+
);
517+
518+
// Mutation direction: removing the provider gate would count the 700
519+
// reasoning bytes, project 11.0%, and make this assertion fail.
520+
expect(runTrigger({ providerID: "opencode" })).toMatchObject({
521+
shouldFire: true,
522+
reason: "projected_headroom",
523+
});
524+
// Anthropic safely clears reasoning through its empty-content sentinel,
525+
// so the aged bytes stay projected as reclaimable and suppress this fire.
526+
expect(runTrigger({ providerID: "anthropic" })).toEqual({ shouldFire: false });
527+
// Pi clears typed thinking for every provider, so its explicit shared
528+
// trigger capability retains the same projection on an OpenCode-like id.
529+
expect(runTrigger({ providerID: "opencode", canClearReasoning: true })).toEqual({
530+
shouldFire: false,
531+
});
532+
});
533+
472534
it("does not force-fire at 80% when pending drops are enough to bring usage below target", () => {
473535
useTempDataHome("compartment-trigger-force-skip-");
474536
createOpenCodeDb("ses-force-skip", [

‎packages/plugin/src/hooks/magic-context/compartment-trigger.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
type RawMessage,
3232
} from "./read-session-raw";
3333
import { estimateTrueRawMessageTokens } from "./read-session-true-raw-tokens";
34+
import { modelAcceptsEmptyContent } from "./sentinel";
3435

3536
const PROACTIVE_TRIGGER_OFFSET_PERCENTAGE = 2;
3637
const POST_DROP_TARGET_RATIO = 0.75;
@@ -77,6 +78,16 @@ export interface InMemoryTailSource {
7778
absoluteMessageCount: number;
7879
}
7980

81+
/**
82+
* Wire capability for projecting reclaimable reasoning bytes. OpenCode derives
83+
* this from the provider's empty-sentinel support; Pi overrides it because its
84+
* serializers safely omit cleared thinking for every provider.
85+
*/
86+
export interface ReasoningProjectionCapability {
87+
providerID?: string;
88+
canClearReasoning?: boolean;
89+
}
90+
8091
/**
8192
* Deferred tail conversion for callers that have already tagged the live wire.
8293
* The trigger's cheap gate can use the caller-provided tag floor without building
@@ -207,8 +218,9 @@ function estimateProjectedPostDropPercentage(
207218
sessionId: string,
208219
usage: ContextUsage,
209220
activeTags: readonly TagEntry[],
210-
clearReasoningAge?: number,
211-
clearedReasoningThroughTag?: number,
221+
clearReasoningAge: number | undefined,
222+
clearedReasoningThroughTag: number | undefined,
223+
canClearReasoning: boolean,
212224
): number | null {
213225
// Denominator must include both text/tool bytes and reasoning bytes to match the numerator
214226
const totalActiveBytes = activeTags.reduce(
@@ -234,7 +246,11 @@ function estimateProjectedPostDropPercentage(
234246
// projected as droppable here. The tiered emergency drop fires only at ≥85%,
235247
// which is above this trigger's window, so it is intentionally not modeled.)
236248
const maxTag = activeTags.reduce((max, t) => Math.max(max, t.tagNumber), 0);
237-
if (clearReasoningAge !== undefined && clearedReasoningThroughTag !== undefined) {
249+
if (
250+
canClearReasoning &&
251+
clearReasoningAge !== undefined &&
252+
clearedReasoningThroughTag !== undefined
253+
) {
238254
const reasoningAgeCutoff = maxTag - clearReasoningAge;
239255
for (const tag of activeTags) {
240256
if (tag.type !== "message") continue;
@@ -418,6 +434,7 @@ export function checkCompartmentTrigger(
418434
contextLimit?: number,
419435
inMemoryTail?: InMemoryTailSource | LazyInMemoryTailSource,
420436
taggerFloorOverride?: number,
437+
reasoningProjection?: ReasoningProjectionCapability,
421438
): CompartmentTriggerResult {
422439
if (sessionMeta.compartmentInProgress) {
423440
sessionLog(
@@ -588,13 +605,21 @@ export function checkCompartmentTrigger(
588605
return { shouldFire: false };
589606
}
590607

608+
// Never project reclaimed reasoning unless this harness can actually clear
609+
// it from the provider wire. The OpenCode fallback shares the sentinel
610+
// predicate with the postprocess clearing path; Pi explicitly supplies its
611+
// own provider-independent capability.
612+
const canClearReasoning =
613+
reasoningProjection?.canClearReasoning ??
614+
modelAcceptsEmptyContent(reasoningProjection?.providerID);
591615
const projectedPostDropPercentage = estimateProjectedPostDropPercentage(
592616
db,
593617
sessionId,
594618
usage,
595619
preloadedActiveTags ?? getActiveTagsBySession(db, sessionId),
596620
clearReasoningAge,
597621
sessionMeta.clearedReasoningThroughTag,
622+
canClearReasoning,
598623
);
599624
const relativePostDropTarget = executeThresholdPercentage * POST_DROP_TARGET_RATIO;
600625

‎packages/plugin/src/hooks/magic-context/transform.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1600,6 +1600,7 @@ export function createTransform(deps: TransformDeps) {
16001600
boundaryContextLimit,
16011601
inMemoryTail,
16021602
taggerFloor,
1603+
{ providerID: resolvedProviderID },
16031604
);
16041605
if (triggerResult.shouldFire) {
16051606
sessionLog(

0 commit comments

Comments
 (0)