Skip to content

Commit 56bfc78

Browse files
Merge pull request #550 from corbitsdev/cl-6933-reset-context-meter-on-new-clear-and-after-compaction
Reset the context meter on clear and after compaction
2 parents 4e9ede9 + 01f8c6e commit 56bfc78

10 files changed

Lines changed: 309 additions & 19 deletions

File tree

src/agent/compaction.test.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ describe("compaction governor", () => {
103103
expect(actions?.some((a) => a.type === "infer")).toBe(false);
104104
expect(continuations).toBe(1);
105105

106-
expect(governor.resumeAfterCompact(emptyMessage())).toBe(true);
107-
expect(governor.resumeAfterCompact(emptyMessage())).toBe(false);
106+
expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer");
107+
expect(governor.resumeAfterCompact(emptyMessage())).toBeNull();
108108
});
109109

110110
test("stays inert below the threshold or with few turns", () => {
@@ -134,7 +134,7 @@ describe("compaction governor", () => {
134134
test("recovers from context overflow a bounded number of times", () => {
135135
const governor = createCompactionGovernor(() => {});
136136
expect(governor.interceptOverflow(overflowError(), capabilities)).not.toBeNull();
137-
expect(governor.resumeAfterCompact(emptyMessage())).toBe(true);
137+
expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer");
138138
expect(governor.interceptOverflow(overflowError(), capabilities)).not.toBeNull();
139139
expect(governor.interceptOverflow(overflowError(), capabilities)).toBeNull();
140140

@@ -162,6 +162,38 @@ describe("compaction governor", () => {
162162
expect(governor.interceptIdleContinuation(emptyMessage(), capabilities)).toBeNull();
163163
});
164164

165+
// Idle compact with an empty continuation previously left postCompactInfer
166+
// unset, so resumeAfterCompact never fired and notePostCompact never ran —
167+
// the Ctx meter stayed on pre-compact lastTurnUsage until the next user turn.
168+
test("idle empty compact syncs the meter after shrink without a following user turn", () => {
169+
let continuations = 0;
170+
const governor = createCompactionGovernor(() => continuations++);
171+
const large = turnsOfLength(10, 200);
172+
governor.noteInferenceDone(inferenceDone(overThreshold), large);
173+
expect(governor.usingEstimate).toBe(false);
174+
const before = governor.estimatedTokens;
175+
176+
governor.noteIdleTurn(inferenceDone(overThreshold), [{ type: "reply", content: "done" }]);
177+
expect(continuations).toBe(1);
178+
179+
const actions = governor.interceptIdleContinuation(emptyMessage(), capabilities);
180+
expect(actions).toEqual([
181+
{ type: "compact", compactor: "pruning-compactor", reason: "context-threshold" },
182+
] as ReactorAction[]);
183+
// A second continuation re-enters decide after the compact cycle so the
184+
// governor can adopt the shrunk turns — without starting a new inference.
185+
expect(continuations).toBe(2);
186+
187+
const shrunk = turnsOfLength(3, 20);
188+
// resumeAfterCompact must arm the meter-only path (not infer) for empty idle.
189+
expect(governor.resumeAfterCompact(emptyMessage())).toBe("meter");
190+
governor.notePostCompact(shrunk);
191+
192+
expect(governor.usingEstimate).toBe(true);
193+
expect(governor.estimatedTokens).toBeLessThan(before);
194+
expect(governor.estimatedTokens).toBe(governor.syncFromTurns(shrunk));
195+
});
196+
165197
test("an operator message that races the idle continuation still compacts, then re-infers", () => {
166198
let continuations = 0;
167199
const governor = createCompactionGovernor(() => continuations++);
@@ -177,7 +209,7 @@ describe("compaction governor", () => {
177209
// A second continuation is requested so the operator message gets answered
178210
// after the compact cycle.
179211
expect(continuations).toBe(2);
180-
expect(governor.resumeAfterCompact(emptyMessage())).toBe(true);
212+
expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer");
181213
});
182214

183215
test("idle turns with follow-up work or under threshold never arm idle compaction", () => {
@@ -337,6 +369,25 @@ describe("compaction governor", () => {
337369
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
338370
});
339371

372+
test("notePostCompact syncs the shrunk turns and keeps the estimate authoritative until the next inference.done", () => {
373+
const governor = createCompactionGovernor(() => {});
374+
const large = turnsOfLength(10, 200);
375+
governor.noteInferenceDone(inferenceDone(overThreshold), large);
376+
expect(governor.usingEstimate).toBe(false);
377+
const before = governor.estimatedTokens;
378+
379+
const shrunk = turnsOfLength(3, 20);
380+
governor.notePostCompact(shrunk);
381+
382+
expect(governor.usingEstimate).toBe(true);
383+
expect(governor.estimatedTokens).toBeLessThan(before);
384+
expect(governor.estimatedTokens).toBe(governor.syncFromTurns(shrunk));
385+
386+
// Provider-reported usage on the next turn clears the estimate flag.
387+
governor.noteInferenceDone(inferenceDone(1000), shrunk);
388+
expect(governor.usingEstimate).toBe(false);
389+
});
390+
340391
test("does not re-arm after a compact that remains over the high watermark", () => {
341392
const governor = createCompactionGovernor(() => {});
342393
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);

src/agent/compaction.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export function createCompactionGovernor(
4040
let pending = false;
4141
let idlePending = false;
4242
let postCompactInfer = false;
43+
// Idle empty compact needs a post-compact decide cycle to adopt the shrunk
44+
// turns for the meter, but must not start a new inference (there is no
45+
// operator question to answer). Distinct from postCompactInfer.
46+
let postCompactMeter = false;
4347
let overflowRecoveries = 0;
4448
// Set whenever the arming decision fell back to the local estimate because
4549
// the provider omitted usage or reported zero, so callers rendering a meter
@@ -167,13 +171,17 @@ export function createCompactionGovernor(
167171
idlePending = false;
168172
pending = false;
169173
const content = typeof event.message.content === "string" ? event.message.content : "";
170-
// An operator message that raced the continuation is already in history;
171-
// compact first, then request another continuation to answer it.
174+
// The reactor delivers no event after compact, so always request a
175+
// continuation to re-enter decide against the shrunk turns:
176+
// - raced operator content → re-infer to answer it
177+
// - empty synthetic continuation → meter-only sync (no infer)
172178
if (content.length > 0) {
173179
postCompactInfer = true;
174-
requestContinuation?.();
180+
} else {
181+
postCompactMeter = true;
175182
}
176183
noteCompactIssued();
184+
requestContinuation?.();
177185
return [capabilities.compact(COMPACTOR_NAME, "context-threshold")];
178186
}
179187

@@ -197,12 +205,30 @@ export function createCompactionGovernor(
197205
return [capabilities.compact(COMPACTOR_NAME, "context-overflow")];
198206
}
199207

200-
function resumeAfterCompact(event: ReactorInboundEvent): boolean {
201-
if (!postCompactInfer || event.type !== "message.received") return false;
208+
// After compact, a content-less continuation re-enters decide. "infer" means
209+
// resume the interrupted loop; "meter" means adopt the shrunk turns for the
210+
// Ctx display and stay idle (idle empty compact has nothing to answer).
211+
function resumeAfterCompact(event: ReactorInboundEvent): "infer" | "meter" | null {
212+
if (event.type !== "message.received") return null;
202213
const content = typeof event.message.content === "string" ? event.message.content : "";
203-
if (content.length > 0) return false;
204-
postCompactInfer = false;
205-
return true;
214+
if (content.length > 0) return null;
215+
if (postCompactInfer) {
216+
postCompactInfer = false;
217+
return "infer";
218+
}
219+
if (postCompactMeter) {
220+
postCompactMeter = false;
221+
return "meter";
222+
}
223+
return null;
224+
}
225+
226+
// After a successful compact, the provider-reported usage from before the
227+
// shrink is stale. Re-sync from the compacted turns and treat the local
228+
// estimate as authoritative until the next real inference.done.
229+
function notePostCompact(turns: readonly ConversationTurn[]): void {
230+
syncFromTurns(turns);
231+
usingEstimate = true;
206232
}
207233

208234
return {
@@ -217,6 +243,7 @@ export function createCompactionGovernor(
217243
},
218244
syncFromTurns,
219245
noteInferenceDone,
246+
notePostCompact,
220247
noteIdleTurn,
221248
interceptActions,
222249
interceptIdleContinuation,

src/agent/director.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -651,7 +651,14 @@ class ChatDirectorImpl extends DefaultDirector {
651651
state: ReactorState,
652652
capabilities: ReactorCapabilities,
653653
): Promise<ReactorAction | ReactorAction[]> {
654-
if (this.compaction.resumeAfterCompact(event)) {
654+
const afterCompact = this.compaction.resumeAfterCompact(event);
655+
if (afterCompact !== null) {
656+
// Compacted history is the live occupancy until the next provider-
657+
// reported inference.done; paint from the estimate in the meantime.
658+
this.compaction.notePostCompact(state.turns ?? []);
659+
// Idle empty compact only needed the decide re-entry to sync the meter;
660+
// stay idle rather than starting an unprompted inference.
661+
if (afterCompact === "meter") return capabilities.wait();
655662
return capabilities.infer();
656663
}
657664
const idleCompact = this.compaction.interceptIdleContinuation(event, capabilities);

src/cost/cost-summary.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
buildCostSummary,
66
formatCostCommandOutput,
77
formatStatusBarSegments,
8+
maskContextMeterWhenNoTurns,
89
} from "./cost-summary.js";
910
import type { CostSummaryInput } from "./cost-summary.js";
1011

@@ -64,6 +65,25 @@ describe("buildCostSummary", () => {
6465
});
6566
});
6667

68+
describe("maskContextMeterWhenNoTurns", () => {
69+
it("hides the meter on a zero-turn session even when contextTokens are non-zero", () => {
70+
const summary = buildCostSummary(baseInput);
71+
expect(summary.contextPercentUsed).toBe(50);
72+
73+
const masked = maskContextMeterWhenNoTurns(summary, 0);
74+
expect(masked.contextPercentUsed).toBeNull();
75+
expect(masked.contextIsEstimate).toBe(false);
76+
// Cost totals stay untouched — only occupancy display is suppressed.
77+
expect(masked.totalCost).toBe(summary.totalCost);
78+
expect(masked.formattedCost).toBe(summary.formattedCost);
79+
});
80+
81+
it("leaves a session with turns unchanged", () => {
82+
const summary = buildCostSummary(baseInput);
83+
expect(maskContextMeterWhenNoTurns(summary, 1)).toEqual(summary);
84+
});
85+
});
86+
6787
describe("formatStatusBarSegments", () => {
6888
it("includes both cost and context when cost is not hidden", () => {
6989
const summary = buildCostSummary(baseInput);

src/cost/cost-summary.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ export function buildCostSummary(input: CostSummaryInput): CostSummary {
5353
};
5454
}
5555

56+
// Zero-turn sessions (fresh launch, post-/clear, post-/new) have no occupancy
57+
// to report. Hide the meter rather than showing 0% or the new director's
58+
// system-prompt/tool-schema overhead as if it were live usage. Cost totals
59+
// stay untouched.
60+
export function maskContextMeterWhenNoTurns(summary: CostSummary, turnCount: number): CostSummary {
61+
if (turnCount > 0) return summary;
62+
return { ...summary, contextPercentUsed: null, contextIsEstimate: false };
63+
}
64+
5665
export interface StatusBarCostSegments {
5766
costLabel?: string;
5867
contextLabel: string;

src/director.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,44 @@ describe("chatDirector compaction", () => {
363363
expect(compactActions).toEqual([
364364
{ type: "compact", compactor: "pruning-compactor", reason: "context-threshold" },
365365
]);
366+
// Idle empty compact schedules a second continuation so decide can adopt
367+
// the shrunk turns for the meter without starting a new inference.
368+
expect(continuations).toBe(2);
369+
});
370+
371+
test("idle empty compact makes the post-compact estimate authoritative without inferring", async () => {
372+
const director = createChatDirector("", [], {
373+
onTasksChange: () => {},
374+
requestContinuation: () => {},
375+
});
376+
const largeTurns = Array.from(
377+
{ length: compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS) + 1 },
378+
(_, i) => ({
379+
role: i % 2 === 0 ? "user" : "assistant",
380+
content: [{ type: "text", text: "x".repeat(200) }],
381+
timestamp: i,
382+
}),
383+
);
384+
const longState = { turns: largeTurns } as unknown as ReactorState;
385+
386+
await director.decide(textInferenceDone(999_999), longState, mockCapabilities);
387+
expect(director.getContextEstimate().isEstimate).toBe(false);
388+
const before = director.getContextEstimate().tokens;
389+
390+
await director.decide(messageReceived(""), longState, mockCapabilities);
391+
392+
// Simulate the reactor having compacted, then the meter-sync continuation.
393+
const shrunkTurns = largeTurns.slice(-3);
394+
const shrunkState = { turns: shrunkTurns } as unknown as ReactorState;
395+
const afterActions = actionsArray(
396+
await director.decide(messageReceived(""), shrunkState, mockCapabilities),
397+
);
398+
expect(afterActions.some((a) => a.type === "infer")).toBe(false);
399+
expect(afterActions.some((a) => a.type === "wait" || a.type === "reply")).toBe(true);
400+
401+
const estimate = director.getContextEstimate();
402+
expect(estimate.isEstimate).toBe(true);
403+
expect(estimate.tokens).toBeLessThan(before);
366404
});
367405

368406
// One turn past createPruningCompactor's own no-op floor (session/compactor.ts).

src/subagent/nudge-director.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,14 @@ export class SubAgentDirector extends DefaultDirector {
179179
state: ReactorState,
180180
capabilities: ReactorCapabilities,
181181
): Promise<ReactorAction | ReactorAction[]> {
182-
if (this.compaction.resumeAfterCompact(event)) {
182+
const afterCompact = this.compaction.resumeAfterCompact(event);
183+
if (afterCompact !== null) {
184+
// Compacted history is the live occupancy until the next provider-
185+
// reported inference.done; paint from the estimate in the meantime.
186+
this.compaction.notePostCompact(state.turns ?? []);
187+
// Idle empty compact only needed the decide re-entry to sync the meter;
188+
// stay idle rather than starting an unprompted inference.
189+
if (afterCompact === "meter") return capabilities.wait();
183190
return this.applyPendingNudge([capabilities.infer()], capabilities);
184191
}
185192
const idleCompact = this.compaction.interceptIdleContinuation(event, capabilities);

0 commit comments

Comments
 (0)