From 44c96a40997d3295f322b0464094a9dcf28f893f Mon Sep 17 00:00:00 2001 From: ework-agent Date: Wed, 9 Sep 2026 23:02:24 +0800 Subject: [PATCH] fix(nudge): gate count-triggered tier nudges on the min usage band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The growthReady branch's COUNT paths (t2Count >= tier2Trigger / t3Count >= tier3Trigger) fired at ANY usage once the block count was reached — in production a T2-distill nudge injected at 43% usage with only 5872 pending tokens (#237), burning a model turn + cache for negligible reclaim. Gate the count paths on usage >= nudge.minContextLimitPct, the same band the first-sight mass bypass (#194) already uses. The token-mass paths stay ungated: they already require a real 1.5x mass. The idle reason now labels count-ready-but-gated tiers as 'T2 N blocks (count, usage-gated)' so acp_status explains why nothing injected. Fixes ranxianglei/acp-kernel#237 --- tests/nudge.test.ts | 64 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/nudge.test.ts b/tests/nudge.test.ts index 32e3c6b..3e174fc 100644 --- a/tests/nudge.test.ts +++ b/tests/nudge.test.ts @@ -750,6 +750,70 @@ test("arbitration: tiers disabled -> count trigger cannot fire T2", () => { assert.doesNotMatch(turn.nudge.reason ?? "", /tier2Trigger/); }); +// #237: the COUNT path can be reached with tiny summary mass (5.8K tokens in +// the production repro). Below the min usage band there is no pressure, so +// distilling there burns a model turn + cache for negligible reclaim. The +// count path is gated on the same band the first-sight mass bypass uses; the +// token-mass path stays ungated (it already requires a real 1.5x mass). +test("arbitration: count-triggered T2 stays silent below the min usage band (#237)", () => { + const core = createCore(); + const config = buildConfig({ preserveRecentMessages: 30 }); + const messages = makeMessages(30); + let state = core.processTurn({ messages, state: createInitialState(), config, tokenCount: 10000 }).state; + // 5 blocks x ~100-token summaries = ~500 pending tokens, far below the + // 9000 token gate — only the count path (5 >= tier2Trigger 5) is ready. + state = { ...state, blocks: t1Blocks([["m1"], ["m2"], ["m3"], ["m4"], ["m5"]], 400) }; + const turn = core.processTurn({ messages, state, config, tokenCount: 43000 }); + assert.equal(turn.nudge.shouldInject, false, `reason: ${turn.nudge.reason}`); + assert.equal(turn.nudge.tier, null); + assert.match(turn.nudge.reason ?? "", /T2 5 blocks \(count, usage-gated\)/); +}); + +test("arbitration: count-triggered T3 stays silent below the min usage band (#237)", () => { + const core = createCore(); + const config = buildConfig({ preserveRecentMessages: 30 }); + const messages = makeMessages(30); + let state = core.processTurn({ messages, state: createInitialState(), config, tokenCount: 10000 }).state; + // 2 tier-1 blocks (below tier2Trigger) + 10 tier-2 blocks with ~100-token + // summaries: t3Pen ~1000 << 9000 token gate — only the count path fires. + state = { + ...state, + blocks: [...t1Blocks([["m1"], ["m2"]], 400), ...t2Blocks(10, 400, ["m3"])], + }; + const turn = core.processTurn({ messages, state, config, tokenCount: 43000 }); + assert.equal(turn.nudge.shouldInject, false, `reason: ${turn.nudge.reason}`); + assert.equal(turn.nudge.tier, null); + assert.match(turn.nudge.reason ?? "", /T3 10 blocks \(count, usage-gated\)/); +}); + +test("arbitration: count trigger fires AT the min usage band boundary (#237)", () => { + const core = createCore(); + const config = buildConfig({ preserveRecentMessages: 30 }); + const messages = makeMessages(30); + let state = core.processTurn({ messages, state: createInitialState(), config, tokenCount: 10000 }).state; + state = { ...state, blocks: t1Blocks([["m1"], ["m2"], ["m3"], ["m4"], ["m5"]], 400) }; + const turn = core.processTurn({ messages, state, config, tokenCount: 45000 }); + assert.equal(turn.nudge.shouldInject, true, `reason: ${turn.nudge.reason}`); + assert.equal(turn.nudge.tier, 2); + assert.match(turn.nudge.reason ?? "", /5 tier-1 blocks >= tier2Trigger 5/); +}); + +test("arbitration: T2 token-mass path still fires below the min usage band (#237 scope)", () => { + const core = createCore(); + const config = buildConfig({ preserveRecentMessages: 30 }); + const messages = makeMessages(30); + let state = core.processTurn({ messages, state: createInitialState(), config, tokenCount: 10000 }).state; + // 5 blocks x 7200 chars = 9000 tokens >= tier2Threshold 9000 (1.5x) and + // > T1 effective 0: the MASS path is ready. Count is also ready (5 >= 5) + // but usage 43% < 45% gates it — the nudge must still fire via mass, + // labeled as the mass path. + state = { ...state, blocks: t1Blocks([["m1"], ["m2"], ["m3"], ["m4"], ["m5"]], 7200) }; + const turn = core.processTurn({ messages, state, config, tokenCount: 43000 }); + assert.equal(turn.nudge.shouldInject, true, `reason: ${turn.nudge.reason}`); + assert.equal(turn.nudge.tier, 2); + assert.match(turn.nudge.reason ?? "", /9000 \(1\.5x\)/); +}); + // #194 first-sight mass bypass: a session that ARRIVES with a huge ready mass // (stateless full-history ingest) must not wait a full growth floor of NEW // tokens for its first compress. #351: growthReference seeded to the ingest