Skip to content

fix(#325): floor meter at provider anchor minus tokens reclaimed since it - #328

Open
ranxianglei wants to merge 1 commit into
masterfrom
2026-09-08_post-compress-token-floor
Open

fix(#325): floor meter at provider anchor minus tokens reclaimed since it#328
ranxianglei wants to merge 1 commit into
masterfrom
2026-09-08_post-compress-token-floor

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

Fixes #325.

Problem

After a successful compress, ACP's own token accounting dropped ~70–80K below the provider-reported prompt size, so the nudge meter flipped to idle. On the next turn the host-floor anchor (#257) snapped tokenCount back up to the real prompt size (~84–95% of the effective limit) and re-injected an EMERGENCY "compress now" nudge. The model compressed again → loop. The agent stopped working every few turns just to compress.

Root cause

src/index.ts sets tokenCount = estimateTokens(coreMessages, coveredIds, imageTokens) + systemPromptTokens, then floors it at the provider-reported prompt size — unless usageAnchorPredatesCompression() is true (a successful compress landed after the last valid assistant usage), in which case the floor was skipped entirely.

  • estimateTokens (src/tokens.ts) sums message text only. It structurally omits tool-definition schemas, ref-tag overhead, and the injected nudge — a fixed ~70–80K on this host. So the raw sent-view is always that far below the real request.
  • Pre-compression, the current-anchor floor lifted tokenCount to the provider number — which is why pre-compression estimates "matched the provider exactly."
  • Post-compression, the stale-anchor skip dropped tokenCount back to the undercounting raw estimate → false idle. Next turn, fresh provider usage re-engaged the floor at full real size → spurious EMERGENCY.

The #289 view-recount inherited the same blind spot because it compares against the post-processTurn view.

Fix

When the anchor predates a successful compress, floor at (lastProviderUsage − netReclaimedSinceAnchor) instead of skipping. netReclaimedSinceAnchor = Σ over active blocks whose creating compress toolResult lands after the last valid usage anchor of max(0, compressedTokens − summaryTokens).

The provider's real number already counts all fixed overhead (tool schemas, etc.) accurately; subtracting only the genuinely-freed tokens yields an exact post-compression size with no need to measure tool schemas — which are unmeasurable/unstable inside the extension (no API enumerates Pi's full tool set). This is why the issue's suggested direction ("add the fixed component to the estimate") was not taken: it would need an unstable magic constant.

New work added after the compress still grows tokenCount through sentTokens as before; the non-stale path is unchanged; a stale-anchor compress that reclaimed little still correctly trips the nudge.

Tests

  • tests/floor-stale.test.ts: unit tests for compressionAnchorStaleness (fresh / stale / pre-anchor exclusion / inactive+unattributable skip / negative-savings clamp / multi-block sum).
  • tests/sent-view-arbitration.test.ts: rewrote the stale test to seed a real reclaiming block (no false emergency), plus a guard test asserting a tiny-reclaim + near-limit context still fires.
  • Full suite: 472 tests, 469 pass, 0 fail (3 pre-existing skips). Typecheck + tsup build green.

Notes

  • No version bump (release-branch territory per AGENTS.md).

…e it

After a successful compress, usageAnchorPredatesCompression() skipped the
provider-usage floor entirely, so tokenCount fell back to the raw sent-view
estimate — which structurally omits tool-definition schemas, ref-tag overhead,
and the injected nudge (a fixed ~70–80K for this host). The meter read
false-idle; on the next turn a fresh provider usage re-engaged the floor at the
full real prompt size (~84–95% of limit) and fired a spurious EMERGENCY, which
the model answered by compressing again → loop.

Fix: when the anchor predates a successful compress, floor at
(lastProviderUsage − netReclaimedSinceAnchor) instead of skipping. netReclaimed
sums max(0, compressedTokens − summaryTokens) over active blocks whose creating
compress toolResult lands after the last valid usage anchor. The provider's
real number already counts all fixed overhead accurately, so subtracting only
the genuinely-freed tokens yields an exact post-compression size without needing
to measure tool schemas (unmeasurable/unstable in the extension). New work added
after the compress still grows tokenCount via sentTokens as before.

No regression on the non-stale path (floor unchanged); a stale-anchor compress
that reclaimed little still correctly trips the nudge.
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Reviewing now — pulling the PR diff, re-verifying the root-cause chain independently, and running the full suite locally before giving a verdict. Will report back shortly.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

Reviewed end-to-end — the fix logic is correct (root cause independently verified, floor math proven in both quadrants), but the PR cannot land as-is: it is dirty against current master, because master refactored exactly the floor region after your branch point. Details below.

What I verified

  • Pulled head 577a7ac locally; its own suite: 472 / 469 pass / 0 fail / 3 skip — matches your claim exactly. Typecheck + tsup green.
  • Traced the attribution chain independently: the kernel stamps compressCallId onto ranges at apply time and copies it onto the created block (acp-kernel bundle :881 / :2474); the adapter passes the host toolCallId through (src/compress-tool.ts:57,329-330). CompressionBlock.active/compressCallId/compressedTokens/summary exist in both 0.0.48 (your pin) and 0.0.56 (master's pin). The old code already required toolResult entries to carry toolCallId, so the new attribution adds no structural assumption.
  • Floor math proven end-to-end with instrumented runs: stale anchor 175K − 120K reclaimed → meter floors at 55,006 (30.6% < 45% band → correctly idle, no false EMERGENCY); stale anchor 174K − 1K reclaimed → floors at 173,006 (96.1% → nudge still fires). Both quadrants behave as designed.

Design verdict

Approving the approach over the alternative: subtracting genuinely-freed tokens from the provider's real number is exact and needs no magic constant; "add the fixed component back to the estimate" would require measuring tool schemas that are unmeasurable/unstable inside the extension. Right call.

Blocker: dirty merge vs master

Branch point is cbb753a; master has since absorbed #289 (sent-view recount), #316/#269 (growth re-inject), #268 (scale-flip reset, panel denominator), #323 (in-memory state save), and release v0.1.59 (acp-kernel 0.0.48 → 0.0.56) — all touching the floor region you modify. Conflicts in src/index.ts (imports + floor block) and tests/sent-view-arbitration.test.ts.

I resolved it locally and validated: full suite 633 / 630 pass / 0 fail / 3 skip, typecheck + build green. Recipe for the rebase:

  1. Keep master's structure (armedFloor, applyFloors, the view-recount gate) and replace the stale-skip with:
    hostFloor = realPromptTokens > 0 ? Math.max(realPromptTokens - (predates ? netReclaimed : 0), 0) : 0
    tokenCount = Math.max(sentTokens, hostFloor, armedFloor)
  2. Semantic decision worth calling out: the ruler-flip signal passed to runtime.noteTokenScale must be estScaleWins = hostFloor <= sentTokens, not raw predates. Under the adjusted floor, a stale anchor with small reclaim still lets the provider floor win (no actual ruler switch); resetting nudge tracking there would zero lastNudgeShownTokens so the next nudge passes the growth gate immediately — reintroducing exactly the [bug] Post-compression sent-view undercounts prompt by ~70–80K → false idle → host-floor emergency re-nudge loop #325 churn this PR exists to kill. Fresh-quadrant behavior is unchanged except a rare base-wins case that now also resets (more accurate).
  3. Test adaptations needed on the rebased tree:
    • growth-scale-flip: seed a real 150K-reclaim block before turn 1 — otherwise the no-block stale path floors turn 1 at the full 175K and it is not estimate-scale anymore (its baseline assertion breaks).
    • sent-view-arbitration stale case: needs a real reclaiming block plus enough compressible mass outside the protected recent zone (MID pad + ~1K reclaim → 96.1% → fires). With the SMALL pad the whole stream sits inside the protection zone and the kernel legitimately suppresses ("max pending < min benefit 5000") — correct kernel behavior, not a bug.

Minor finding (report-only, no separate issue per policy)

/acp's statusReport (src/commands.ts:153,171) applies the same stale-skip inside a display-only processTurn, so the panel under-reports right after a compress until fresh provider usage arrives. Cosmetic, self-heals next turn, no loop risk — fold into the rebase or take as later cleanup.

Recommendation: rebase onto current master with the recipe above (happy to share the exact hunks if useful). Fix code itself: LGTM.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] Post-compression sent-view undercounts prompt by ~70–80K → false idle → host-floor emergency re-nudge loop

1 participant