advisor() tool inflates reported input tokens by forwarding full transcript, triggering premature auto-compaction on extended context models

Status Open
Reported on v2.1.111
Maintainer reply None cached
Activity 18 comments · opened Apr 25, 2026

Summary

When advisor() is called, the full conversation transcript is forwarded to a second model (currently claude-opus-4-7). The token usage from both the main executor and the advisor sub-inference are summed in the top-level usage fields. If Claude Code's auto-compaction logic uses these summed totals, the advisor call effectively doubles the apparent context usage, triggering compaction when the main model's actual context is only ~50% full.

Reproduction

  1. Start a Claude Code session with claude-opus-4-6[1m] (1M context)
  2. Work normally until the main context reaches ~400K-500K input tokens
  3. Call advisor()
  4. Observe: auto-compaction fires immediately after the advisor call returns

Evidence from session JSONL

Session ID: c3a29188-290f-4af2-be48-8f6fb6929111
Model: claude-opus-4-6[1m]
Project: CriticalSkip

Token progression at the compaction boundary

| JSONL line | Total reported input | Notes |
|-----------|---------------------|-------|
| 1972 | 513,354 | Normal turn, main context only |
| 1979 | 1,028,789 | advisor() called — reported total doubled |
| 1994 | — | "Conversation compacted" system message |
| 2012 | 35,693 | Post-compaction context (wiped to ~36K) |

Iteration breakdown for the advisor turn (line 1979)

{
  "input_tokens": 4,
  "cache_creation_input_tokens": 1731,
  "cache_read_input_tokens": 1027054,
  "iterations": [
    {
      "type": "message",
      "input_tokens": 3,
      "cache_read_input_tokens": 513353,
      "cache_creation_input_tokens": 348,
      "output_tokens": 35
    },
    {
      "type": "advisor_message",
      "model": "claude-opus-4-7",
      "input_tokens": 701354,
      "cache_read_input_tokens": 0,
      "cache_creation_input_tokens": 0,
      "output_tokens": 5672
    },
    {
      "type": "message",
      "input_tokens": 1,
      "cache_read_input_tokens": 513701,
      "cache_creation_input_tokens": 1383,
      "output_tokens": 544
    }
  ]
}

Key observation: The main model's actual context was 513-515K tokens (iterations 1 and 3). The advisor sub-inference consumed 701K tokens (iteration 2 — the full transcript forwarded uncached). The top-level cache_read_input_tokens reports 1,027,054 — the sum across all iterations — making it appear the context is at 1M when only half is actually used by the executor.

The math

  • Main executor context: ~513K tokens (well within 1M window)
  • Advisor receives full transcript: ~701K tokens (separate model, separate inference)
  • Reported total: ~1,028K tokens (sum of both)
  • Auto-compaction threshold: likely ~95% of 1M ≈ 950K
  • Result: compaction fires at 513K actual context because 1,028K > threshold

Expected behavior

Advisor sub-inference tokens should not count toward the auto-compaction threshold. The advisor is a separate model call with its own context window. The executor's context at 513K is well within the 1M budget and should not trigger compaction.

Actual behavior

The summed total (executor + advisor) exceeds the compaction threshold, and auto-compaction fires immediately. The session loses ~500K tokens of working context unnecessarily.

Impact

  • On extended context models (1M): Any advisor call past ~400K main context will trigger compaction, since 400K main + ~550K advisor = ~950K ≈ threshold
  • Effectively halves usable context: The 1M context window becomes ~400-450K when advisor is used, because the remaining ~550K is reserved for the advisor's copy of the transcript
  • Inconsistent: Early-session advisor calls work fine. Late-session calls compact. Users experience this as random/intermittent compaction
  • Counterproductive: The advisor is most valuable late in a session (complex decisions with full context), but that's exactly when it triggers compaction and destroys the context it was supposed to help with

Workaround

Setting CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=98 and/or using a PreCompact hook that blocks auto-compaction:

{
  "hooks": {
    "PreCompact": [{
      "hooks": [{
        "type": "command",
        "command": "bash -c 'INPUT=$(cat); TRIGGER=$(echo \"$INPUT\" | python3 -c \"import sys,json; print(json.load(sys.stdin).get(\\\"trigger\\\",\\\"unknown\\\"))\" 2>/dev/null); [ \"$TRIGGER\" = \"auto\" ] && echo \"{\\\"decision\\\":\\\"block\\\"}\" && exit 2; exit 0'",
        "timeout": 3
      }]
    }]
  }
}

This blocks all auto-compaction and relies on manual /compact. Not ideal but prevents the advisor from triggering premature compaction.

Related issues

  • #34332 — Opus 4.6 (1M context): autocompact triggers at ~76K tokens
  • #50204 — Auto-compact triggers prematurely with extended context models
  • #15377 — Conversations compacting prematurely at ~65% token capacity
  • #49994 — Sessions using advisor() become permanently unrecoverable (expired encrypted payloads)
  • #42647 — High token burn due to redundant context resubmission in compaction pipeline

Suggested fix

When calculating context usage for the auto-compaction threshold, use only the executor's input_tokens + cache_read_input_tokens + cache_creation_input_tokens from type: "message" iterations. Exclude type: "advisor_message" iterations from the calculation. The advisor tool documentation already states that "top-level max_tokens applies to executor output only" and that "the advisor's tokens do not draw from any task budget applied to the executor" — the compaction logic should follow the same principle.

Environment

  • Claude Code version: 2.1.111
  • Model: claude-opus-4-6[1m]
  • Advisor model: claude-opus-4-7
  • Platform: Windows 10 Pro
  • Settings: advisorModel: "opus", effortLevel: "medium"

View original on GitHub ↗

18 Comments

kerneltoast · 5 months ago

Ditto. I have this exact same issue and even filed an issue (#55702) that turned out to be a duplicate of this one. I'm using Linux with Opus 4.6 [1M] as my main model and Opus 4.7 as the advisor model.

kerneltoast · 5 months ago

I have auto-compaction disabled. My workaround has been to /rewind to right before the advisor call and ask Claude to generate a session handoff file with the next instruction saying to call the advisor. Then I /clear and run @SESSION_HANDOFF.md.

kerneltoast · 5 months ago

This bug still occurs on v2.1.126.

Yesterday I started a new session on v2.1.126 with Opus 4.7 and gave it a single complex prompt using a session handoff file. The context window limit was reached before Claude could completely finish my prompt, and it happened because Claude invoked the advisor at around 480K context tokens which triggered this bug; thus, the context size doubled and my prompt died due to hitting the context limit.

Since my session was literally just a single prompt, I couldn't /rewind back before the advisor call to salvage my session.

Please update the tags to specify that this bug isn't limited to Windows, as I experience this bug in Linux.

The bug is 100% reproducible. It really diminishes the value of the advisor feature.

kerneltoast · 5 months ago

This issue still occurs on v2.1.140. Please, kindly prioritize fixing it as it significantly limits the advisor's potential.

AttacktheDPoint-com · 5 months ago

still happening...

kerneltoast · 4 months ago

Still happens on v2.1.148, and it is still 100% reproducible. I even got a weird one today where 3 back-to-back Advisor calls caused my session to hit the 1M context limit even though I started with 25% of the context window full. So multiple consecutive Advisor calls can bust a session even when you're well below 50% of context filled.

genesiscz · 4 months ago

More info — independently reproduced on claude-opus-4-7[1m], with two facets worth adding.

Hit this on Claude Code 2.1.150 / macOS / claude-opus-4-7[1m]. A single advisor() call took the statusline from ~406K → 1170k/1000k (117%), +782k in one turn. Iteration-level data confirms your diagnosis exactly, and adds (1) a timeout-driven ×3 and (2) confirmation it's display-only — the prompt cache and the executor's real context are untouched (the #17959 symptom).

Same accounting, confirmed

The advisor turn's usage:

// top-level: input=2977  cache_creation=10421  cache_read=1156259  output=3604
"iterations": [
  { "type": "message",         "cache_read_input_tokens": 381449, "cache_creation_input_tokens": 5956, "output_tokens": 2946 },
  { "type": "message",         "input_tokens": 2973, "cache_read_input_tokens": 387405, "output_tokens": 218 },
  { "type": "advisor_message", "model": "claude-opus-4-7", "input_tokens": 395834, "cache_read_input_tokens": 0, "output_tokens": 6026 },
  { "type": "message",         "cache_read_input_tokens": 387405, "cache_creation_input_tokens": 4465, "output_tokens": 440 }
]

381449 + 387405 + 387405 = 1156259 = the top-level cache_read exactly. So the top-level cache_read is Σ cache_read over the executor (type:"message") sub-iterations, and each advisor() invocation adds one executor re-read of the same cached context. General rule:

N advisor invocations in a turn ⇒ the executor's context is counted (N+1)× in the top-level cache_read.

The advisor's transcript forward is the separate advisor_message (input_tokens=395834, uncached) and is not in the top-level cache_read. Control from the same session — a successful, single advisor call on a ~113K context reported cache_read = 113080 + 114484 = 227564 → ×2, matching your 513K→1028K.

Facet 1 — execution_time_exceeded → auto-retry → ×3 (not ×2)

My turn has two server_tool_use{name:"advisor"} blocks. The first returned:

{ "type": "advisor_tool_result_error", "error_code": "execution_time_exceeded" }

…on a ~385K transcript (the reviewer can't finish within its server-side budget), so it auto-retried. Two invocations → three executor message iterations → ×3 (1,156,259), not ×2. The timed-out attempt left no billed advisor_message iteration but cost ~8 min wall-clock and forced the extra re-read at iteration [1]. So on large contexts the inflation multiplier can exceed 2.

Facet 2 — the inflation is display-only; cache and real context are untouched

The 117% does not reflect real context growth. The next turn read cache_read=387405 with cache_creation=7167 — a normal incremental, so the prompt cache stayed fully intact and the executor's real context never left ~385K. The inflated figure is a pure accounting artifact of the summed cache_read, not a real approach to the window limit — exactly the #17959 symptom (context_window.used_percentage not matching the real/internal figure).

The harm this issue raises is that the same summed figure also feeds the auto-compaction trigger, so a complete fix needs both the statusline and the auto-compaction trigger to use the executor's real context and exclude server-tool sub-iteration re-reads of already-cached content.

Honest cost (so the multiplier isn't misread)

  • 1,156,259 is the same ~385K counted 3× at cache_read rate (~0.1×) — cheap (~115K full-rate-equiv).
  • The genuine extra is the uncached advisor_message forward: ~396K full-rate input + 6K output, once.
  • Net real cost ≈ 396K, not 1170K — but it isn't free, and the timeout wasted a full ~8-min attempt.

Environment: Claude Code 2.1.150, claude-opus-4-7[1m], Anthropic API, macOS, Ghostty/zsh.

kerneltoast · 4 months ago

Still happens on v2.1.156. I ran into it with Opus 4.8 (1M) today.

junaidtitan · 4 months ago

The advisor() double-count is nasty because the usage fields in the JSONL show the inflated sum, which is what CC's auto-compact trigger reads — so from CC's perspective you've "hit 1M" even though the actual transcript is half that.

cozempic (github.com/Ruya-AI/cozempic) helps in two ways here, though I want to be honest about what it can and can't do: it doesn't fix the double-counting in CC's token reporting. What it can do:

  1. cozempic current --diagnose reads the raw per-message token counts directly from the JSONL and gives you the real context footprint independent of the running usage total. If you're seeing 1M reported but the actual accumulated transcript is ~500K, the diagnose output will show the discrepancy — which at minimum tells you whether you're hitting a real limit or a counting bug.
  1. The prune side: cozempic strips the bulky advisor/subagent turn outputs (large responses, tool output blocks) from the JSONL to reduce the real baseline. So when an advisor call fires and doubles the apparent count, there's slack in the real transcript that absorbs it.

pip install cozempic / pipx install cozempic. Given this bug has been reproducing on 4.6, 4.7, and 4.8 across multiple people — would be curious whether the diagnose numbers match what you're seeing or surface something different.

kerneltoast · 3 months ago

Still not fixed.

alexrojco · 3 months ago

An abysmal bug and still an issue 2 months later!

kerneltoast · 3 months ago

Still occurs on v2.1.196.

johnsfleming · 2 months ago

Same root cause without advisor() — plain message continuation/retry iterations also trigger it

I hit the same premature auto-compaction mechanism described here, but with no advisor call anywhere in the session — every entry in the iterations arrays is "type": "message". This suggests the fix belongs in the compaction accounting (it should use the actual prompt size of the last/largest single call, not the summed top-level usage), not in the advisor tool specifically.

Environment

  • Claude Code 2.1.217, darwin
  • Model: claude-sonnet-5 (no 1M variant, no advisor, no custom compaction overrides)
  • Session: a398982a-2ad8-48a5-9560-95c2b60fc539 — 44 minutes, 3 auto-compactions

Evidence from the session JSONL

Each compact_boundary's preTokens matches the summed multi-iteration usage of the preceding assistant turn, roughly 2x the real context of the largest single API call:

| Compaction | preTokens | Largest single iteration (real context) | Inflation |
|---|---|---|---|
| #1 (auto) | 269,302 | ~131k (in=709, cache_read=130,705) | ~2.0x |
| #2 (auto) | 167,697 | ~65k (in=55,291, cache_read=9,629 — cache-miss full resend) | ~2.6x |
| #3 (auto) | 171,976 | ~87k (in=640, cache_read=84,035) | ~2.0x |

Iteration breakdown for the turn preceding compaction #2 (top-level usage is the sum of all three calls):

{
  "input_tokens": 55682, "cache_read_input_tokens": 107212, "cache_creation_input_tokens": 3367,
  "iterations": [
    { "type": "message", "input_tokens": 2,     "cache_read_input_tokens": 47108, "cache_creation_input_tokens": 3367 },
    { "type": "message", "input_tokens": 389,   "cache_read_input_tokens": 50475, "cache_creation_input_tokens": 0 },
    { "type": "message", "input_tokens": 55291, "cache_read_input_tokens": 9629,  "cache_creation_input_tokens": 0 }
  ]
}

The third iteration is a cache-miss retry that re-sent the conversation uncached (~55k real context), yet the summed total (~166k) is what the compaction trigger saw. Compaction #2 fired when the window was roughly a third full.

Impact: the three compactions burned ~8.5 minutes of a 44-minute session (durationMs 263,741 / 109,517 / 136,274) and repeatedly discarded working context that was nowhere near the limit. Because retries/continuations can happen on any model, this isn't limited to extended-context models or advisor users.

arthurmoraesfernandes-afk · 2 months ago

Corroborating with an 8-day quantified dataset — #84738 pins this mechanism with transcript-level evidence: a turn that invokes the advisor reports usage summed across usage.iterations — two full-context message iterations, so top-level usage comes out at ~2× real context (e.g. cache reads 515,122 + 515,905 → 1,031,027 reported) — and auto-compact eligibility reads the rolled-up figure. Quantified over 8 days of transcripts on one machine: every subagent auto-compaction in the window fired on the turn immediately after an inflated advisor rollup, at real contexts of 310–517K, average (preTokens − real context) ≈ 350–390K. Subagent seats consulting the advisor at checkpoints are hit hardest.

joshua-angulo · 2 months ago

Confirming the mechanism on 2.1.232 (latest as of 2026-08-13) at the binary level, with account-wide numbers and a tested, end-to-end-verified client-side workaround (PreCompact hook).

Where the false number comes from

The context figure is derived from the last assistant message's top-level usage as input_tokens + cache_creation_input_tokens + cache_read_input_tokens. For a turn containing a server-side tool round trip (the advisor), that top-level object sums every client inference pass, and both the status-line percentage and the auto-compact eligibility check read this same rolled-up sum. So the meter shows ~2× real occupancy with one advisor call, ~3× with two (matches the (N+1)× arithmetic reported above).

The transcript already carries the correct figure: usage.iterations holds the per-pass breakdown, where client passes are type: "message" (2 uncached input tokens each — hence top-level input_tokens = 4 on a one-advisor-call turn) and the advisor's own pass is type: "advisor_message", billed separately. Real occupancy = the last type:"message" iteration's input + cache_read + cache_creation. Example from the phantom compaction below: top-level 772,186 vs iterations-last 386,762.

Impact on one account (120 recent sessions, Fable 5 / Opus 5)

  • 259 advisor requests; 91% roll up, ratio clustered at 2.01–2.05 (one 3.06 with two advisor calls in one turn).
  • 5 of the account's 8 lifetime auto-compactions were phantom: they fired at real occupancies of 295k–432k (~half the window) while the rolled-up figure read 592k–1,108k. One shown figure (1,108,565) exceeds the model window itself, which alone proves the number is not occupancy.
  • All 1,570 rolled-up turns in the corpus involve the advisor; zero plain continuation/retry rollups here (though one is documented earlier in this thread on Sonnet 5, so a guard should not assume advisor-only).

Suggested fix

Compute occupancy from the last type: "message" entry in usage.iterations when present, instead of the top-level rollup — for the status line and for the auto-compact check. The correct number is already in the data.

Workaround: PreCompact veto hook (verified end-to-end on 2.1.232)

A PreCompact hook on the auto matcher re-reads the last assistant usage from transcript_path; if usage.iterations shows ≥2 type:"message" passes and the real (last-pass) occupancy is below the auto-compact threshold, it exits 2 to veto that compaction — the meter recovers by itself on the next turn. Genuine compactions (single pass, or real occupancy actually over the threshold) pass through; manual /compact is untouched; the hook fails open on any error.

Verified two ways:

  1. Replay against real transcripts: blocks the phantom compaction above (shown 772,186 / real 386,762), allows both genuine auto-compactions in the account's history and a rollup turn whose real occupancy (784k) exceeded the threshold.
  2. Live A/B on 2.1.232: two identical headless sessions driven past the auto-compact threshold (window forced to 100k via --settings, threshold 68k). Control: auto-compacted at preTokens 80,625. With an exit-2 PreCompact hook on auto: zero compact_boundary entries, session continued to 109,677 tokens and completed normally.
#!/usr/bin/env python3
"""PreCompact hook: veto phantom auto-compactions caused by usage rollups.

Register under hooks.PreCompact with matcher "auto".
Exit 0 = allow compaction. Exit 2 = block it. Fails open on any error.
"""
import json
import os
import sys

TAIL_BYTES = 4 * 1024 * 1024  # meter reads the LAST assistant usage; tail is enough


def last_assistant_usage(path):
    with open(path, "rb") as f:
        f.seek(0, 2)
        size = f.tell()
        f.seek(max(0, size - TAIL_BYTES))
        chunk = f.read().decode("utf-8", errors="replace")
    lines = chunk.split("\n")
    if size > TAIL_BYTES:
        lines = lines[1:]  # drop the partial first line
    for line in reversed(lines):
        if '"usage"' not in line:
            continue
        try:
            entry = json.loads(line)
        except json.JSONDecodeError:
            continue
        if entry.get("type") != "assistant" or entry.get("isSidechain"):
            continue
        msg = entry.get("message") or {}
        usage = msg.get("usage")
        if not usage or msg.get("model") == "<synthetic>":
            continue
        return usage
    return None


def occupancy(u):
    return (
        (u.get("input_tokens") or 0)
        + (u.get("cache_creation_input_tokens") or 0)
        + (u.get("cache_read_input_tokens") or 0)
    )


def auto_compact_threshold():
    """(window-20000)*pct — the conservative (lower) of the two known formulas,
    so ambiguity favors allowing a genuine compaction. Reads settings.json env
    first, then the process environment. None when no window is configured."""

    def from_source(get):
        window = int(get("CLAUDE_CODE_AUTO_COMPACT_WINDOW") or 0)
        if window <= 0:
            return None
        pct = float(get("CLAUDE_AUTOCOMPACT_PCT_OVERRIDE") or 85)
        return int((window - 20000) * pct / 100)

    try:
        with open(os.path.expanduser("~/.claude/settings.json")) as f:
            env = json.load(f).get("env") or {}
        threshold = from_source(env.get)
        if threshold is not None:
            return threshold
    except Exception:
        pass
    try:
        return from_source(os.environ.get)
    except Exception:
        return None


def veto(reason):
    print(reason, file=sys.stderr)
    return 2


def main():
    try:
        hook_input = json.load(sys.stdin)
    except Exception:
        return 0
    if hook_input.get("trigger") != "auto":
        return 0
    path = hook_input.get("transcript_path")
    if not path:
        return 0
    try:
        usage = last_assistant_usage(path)
    except Exception:
        return 0
    if not usage:
        return 0

    shown = occupancy(usage)
    iterations = usage.get("iterations")
    msg_iters = [
        i for i in (iterations or [])
        if isinstance(i, dict) and i.get("type") == "message"
    ]

    if len(msg_iters) >= 2:
        real = occupancy(msg_iters[-1])
        threshold = auto_compact_threshold()
        if threshold is None or real >= threshold:
            # unknown threshold, or genuinely full even by the real figure —
            # never suppress a compaction we cannot prove phantom
            return 0
        return veto(
            f"Phantom context spike: usage rolled up {len(msg_iters)} passes; "
            f"meter ~{shown:,} but real occupancy ~{real:,}. "
            f"Auto-compact vetoed; the meter recovers next turn."
        )

    # rollup suspected but no usable breakdown (iterations absent or empty)
    if not msg_iters and usage.get("server_tool_use") and (
        (usage.get("input_tokens") or 0) >= 3
    ):
        return veto(
            f"Phantom context spike (no iterations breakdown): server-tool "
            f"turn, meter ~{shown:,} sums multiple passes. "
            f"Auto-compact vetoed; the meter recovers next turn."
        )

    return 0


if __name__ == "__main__":
    sys.exit(main())

Registration (user or project settings):

{
  "hooks": {
    "PreCompact": [
      {
        "matcher": "auto",
        "hooks": [
          {"type": "command", "command": "/path/to/precompact-phantom-guard.py"}
        ]
      }
    ]
  }
}

Known residual: on a model whose true window is below the configured threshold the guard could defer a needed recovery compaction by one request. And per the hooks docs, blocking the error-recovery trigger surfaces the underlying API error for that request — with this guard that path stays reachable because a genuinely full context (real ≥ threshold) is always allowed through.

Studytempest · 1 month ago

Still present on 2.1.255 and 2.1.258 (2026-09-03), on macOS — the thread's platform labels are platform:windows / platform:linux, so adding a third. Corroborating @joshua-angulo's 2.1.232 binary-level confirmation and extending the observed range by ~26 releases.

This is a longitudinal scan of one machine's whole local transcript archive rather than a single session, so it also gives a per-version rate, which I don't think has been posted yet.

Method

Scan every ~/.claude/projects/**/*.jsonl. For each entry carrying compactMetadata.preTokens, take the preceding assistant message's usage and compare:

  • reported = input_tokens + cache_creation_input_tokens + cache_read_input_tokens (top level — what the eligibility check reads)
  • real ≈ max() of the same sum computed per entry in usage.iterations[]

Deduplicated by compaction-record uuid; the same session appears in multiple files on disk, and counting per file inflates every total roughly 2x. Classified as inflated when reported/real > 1.5. Script at the bottom.

Results

176 distinct compaction records; 171 trigger: "auto"; 165 of those have a preceding usage record.

87 of 165 (53%) fired off a rolled-up count.

Ratio is tight and matches the (N+1)x arithmetic already reported here: min 1.97, median 1.98, max 2.00.

Real context at the moment those compactions fired: min 238,215 · median 408,328 · max 757,862 tokens.

Rate by CLI version

| version | clean | inflated | inflated % |
|---|---:|---:|---:|
| 2.1.217 | 8 | 0 | 0% |
| 2.1.219 | 16 | 0 | 0% |
| 2.1.221 | 6 | 8 | 57% |
| 2.1.222 | 13 | 4 | 24% |
| 2.1.227 | 8 | 3 | 27% |
| 2.1.229 | 11 | 9 | 45% |
| 2.1.234 | 0 | 3 | 100% |
| 2.1.237 | 6 | 22 | 79% |
| 2.1.246 | 2 | 6 | 75% |
| 2.1.247 | 5 | 9 | 64% |
| 2.1.255 | 2 | 21 | 91% |
| 2.1.258 | 1 | 2 | 67% |

Two caveats on that table, stated so nobody over-reads it: per-version samples are small (3–28 events), and this is one machine's workload, so the mix of multi-iteration turns is not necessarily representative. What the counts do support is that the rate is not going down across ~40 releases, and the two most recent versions with double-digit samples (2.1.237 at 79%, 2.1.255 at 91%) are the highest in the set.

#84738 reports the rollup format first appearing on 2.1.219. In this archive 2.1.219 is 0/16 and the first inflated event is on 2.1.221 — consistent, slightly later.

Affected models

claude-opus-5 (51), claude-fable-5-1 (23), claude-fable-5 (12), claude-sonnet-5 (1). All 1M-context seats.

Most recent example (2.1.258, today)

2026-09-03T12:51:09  claude-fable-5-1
  usage.iterations[] totals:  ~400,696 each (three passes over the same context)
  top-level reported:          794,695     ( = two of those passes summed )
  real context:                400,696
  ratio:                       1.98

autoCompactWindow on this machine was 750000 at that point, so the effective trigger is 750000 − 20000 − 13000 = 717,000 reported tokens. A 1.98x rollup means compaction fires at roughly 358K real context — under half the configured window, on a 1M seat.

Environment

  • macOS 26.5.2 (arm64)
  • Claude Code 2.1.217 → 2.1.258 (auto-update, latest channel)
  • model: "opus[1m]", autoCompactWindow explicitly set (values varied 500000 / 700000 / 750000 / 800000 across the window scanned — the ratio is independent of it)
  • advisorModel set; the advisor is used regularly, consistent with @johnsfleming's note that plain message continuation/retry iterations trigger it too

Repro script

import json, glob, os, collections, statistics as st
ev = {}
for f in glob.glob(os.path.expanduser("~/.claude/projects/**/*.jsonl"), recursive=True):
    prev = None
    for line in open(f, errors="replace"):
        try: e = json.loads(line)
        except: continue
        u = (e.get("message") or {}).get("usage")
        if isinstance(u, dict):
            tot = sum(u.get(k) or 0 for k in
                      ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"))
            vals = [sum((it.get("usage") or it).get(k) or 0 for k in
                        ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"))
                    for it in (u.get("iterations") or [])]
            if tot: prev = {"tot": tot, "vals": vals, "ver": e.get("version")}
        cm = e.get("compactMetadata")
        if isinstance(cm, dict) and cm.get("preTokens") is not None and prev:
            key = e.get("uuid") or (e.get("sessionId"), cm["preTokens"], e.get("timestamp"))
            ev.setdefault(key, dict(prev, trigger=cm.get("trigger")))

auto = [r for r in ev.values() if r["trigger"] == "auto" and r["tot"]]
infl = [r for r in auto if r["tot"] / (max(r["vals"]) if r["vals"] else r["tot"]) > 1.5]
print(f"{len(infl)}/{len(auto)} auto-compactions fired off a rolled-up count")
by = collections.Counter((r["ver"], r in infl) for r in auto)
for v in sorted({k[0] for k in by}): print(v, "clean", by[(v, False)], "inflated", by[(v, True)])

Why this still matters four months on

The transcript carries the correct per-iteration figure the whole time — usage.iterations[] is right there in the same object the eligibility check already reads. The fix is max() instead of the implicit sum. Meanwhile, on a 1M seat with the advisor in normal use, roughly half of all auto-compactions destroy a session's context at about half the configured window, silently, and the user is told the conversation ran out of room.

axelboman277 · 1 month ago

Still present on 2.1.268 (win32 build 2026-09-10, sha 8d19e585) — with data on why autoCompactEnabled:false or a PreCompact veto alone does not help on a 1M window, and a live-tested workaround that keeps auto-compaction.

Numbers from one machine (Opus 5 [1m] + advisor): 18 auto-compactions across 2.1.252–2.1.268. 17 were phantom: each fired on the request right after a response with usage.iterations = [message, advisor_message, message], and compactMetadata.preTokens was ≈2.0× the last message iteration (e.g. 976,488 vs 481,788; 1,099,753 vs 546,869). 13 of the 18 happened in subagents / workflow agents, which inherit the advisor. The only genuine one fired at a real 925k.

Where the number comes from in 2.1.268 (minified names, byte offsets in the win32 binary):

  • Gm (@191708841) = wK(last assistant usage) + estimate(tail); wK (@191706933) = input + cache_creation + cache_read + output of the top-level usage.
  • Gpe (@191707052) picks the last message/fallback_message iteration — it exists, but only the request journal and rewind use it, not compaction.
  • kFe/spt (@191699491) for a 1M window: compact at ≥ 967,000, blocking limit at ≥ 977,000 (window − 3000), and spt checks blocked before compact.

Why the obvious workarounds fail on 1M (both reproduced live, see Test)

  1. autoCompactEnabled:false / DISABLE_AUTO_COMPACT: the main loop still runs the local pre-flight check (@193554865):

``js
let Jd=!1,hp=Tp()&&xF()&&(…);
if(Yo.kind!=="compacted"&&T!=="compact"&&!hp&&!Jd){
if(fse(Gm(_r,…)-sl,model,autoCompactWindow).level==="blocked") … return{reason:"blocking_limit"}}
`
With auto-compact off,
Tp() is false, so the check runs on the same inflated Gm`: the turn ends with a synthetic "Prompt is too long" at ~488k real and stays stuck, because the synthetic message is skipped when looking up the last usage (matches #81620).

  1. A PreCompact veto (exit 2) on auto: the same check runs right after the hook blocks, so the veto only helps while the inflated figure is in [967k, 977k), i.e. real ≈ 483.5k–488.5k. Above that it turns a phantom compaction into blocking_limit.

Workaround that keeps auto-compaction — both parts are needed

  1. "env": { "CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE": "10000000" } — spt uses it (when > 0) instead of window − 3000; on the main-loop path fse is called with three arguments, so the env value is honored. The API stays the judge of real overflow. Undocumented, may change.
  2. A PreCompact hook on auto that vetoes only phantom compactions: the last response has ≥ 2 message iterations (or a server_tool_use when iterations was not persisted) and the real occupancy — last message iteration + tail estimate + 30k margin — is below the threshold. Genuine compactions still run. It stays safe when the env var is missing (vetoes only inside the safe band) and allows compaction once a synthetic "Prompt is too long" has already appeared.

Two things I learned from the live runs that matter for any hook here:

  • On 2.1.268 the proactive path logs autocompact: routing through reactive, and an exit 2 there is logged as Reactive compact blocked by PreCompact hook → Reactive compact skipped; the turn continues when the blocking level is not reached.
  • The PreCompact input has no agent_id, even when a subagent compacts (keys: custom_instructions, cwd, hook_event_name, prompt_id, session_id, transcript_path, trigger; transcript_path is the main session). So the hook looks at the session transcripts (main + subagents/**/agent-*.jsonl) that ended with a user turn most recently, and vetoes only when all of them are phantom. Records of one response can be interleaved with results of parallel tool calls, so it groups them by message.id.

<details><summary>Hook (tested on 2.1.268, Windows)</summary>

#!/usr/bin/env python
"""Veto phantom auto-compactions caused by summed multi-iteration usage.

Claude Code (checked on 2.1.268) sizes the context from the top-level ``usage`` of the last
assistant response.  When one response spans several sampling iterations -- the advisor server
tool renders as ``message -> advisor_message -> message`` -- the API reports that top-level usage
as the SUM over the executor ``message`` iterations, so the context looks about twice its real
size and auto-compaction fires at roughly half the window.  The real size is the last ``message``
iteration, and the transcript already carries it in ``usage.iterations``.

Registered on ``PreCompact`` with matcher ``auto``: exit 2 vetoes the compaction, exit 0 allows
it.  Any error or doubt allows it.  Every decision is appended to a JSON-lines log.

A veto is safe only together with ``CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE``: right after a vetoed
compaction Claude Code runs a pre-flight blocking check on the same inflated figure.  Without the
variable the hook vetoes only while that check would still pass.

The hook input does not reliably tell which context is compacting.  The compacting context has
just written its tool results and is about to send a request, so the hook takes the session
transcripts that end with a user turn and were written most recently, and vetoes only when all
of them are phantom.
"""

from __future__ import annotations

import glob
import json
import math
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

TAIL_BYTES = 8 * 1024 * 1024
RECENT_SECONDS = 30.0
FRESH_SECONDS = 2.0
SAFETY_MARGIN = 30_000
BLOCKING_MARGIN = 5_000
ADVISOR_RESULT_ALLOWANCE = 5_000
SMALL_WINDOW = 200_000
LARGE_WINDOW = 1_000_000
MIN_CONFIGURED_WINDOW = 100_000
SUMMARY_BUFFER = 20_000
COMPACT_BUFFER = 13_000
BLOCKING_BUFFER = 3_000
SYNTHETIC_MODEL = "<synthetic>"
PASS_TYPES = ("message", "fallback_message")
DEFAULT_LOG = Path.home() / ".claude" / "hooks" / "logs" / "precompact-phantom-guard.jsonl"
LOG_MAX_BYTES = 5 * 1024 * 1024


def _read_hook_input() -> dict:
    try:
        raw = sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
        data = json.loads(raw) if raw.strip() else {}
    except (OSError, ValueError):
        return {}
    return data if isinstance(data, dict) else {}


def _number(value) -> float | None:
    try:
        number = float(value)
    except (TypeError, ValueError):
        return None
    return None if math.isnan(number) else number


def _fmt(number: float) -> str:
    return f"{int(number):,}".replace(",", " ")


def _message(record: dict) -> dict:
    message = record.get("message")
    return message if isinstance(message, dict) else {}


def _occupancy(usage: dict) -> int:
    return sum(
        int(usage.get(key) or 0)
        for key in (
            "input_tokens",
            "cache_creation_input_tokens",
            "cache_read_input_tokens",
            "output_tokens",
        )
    )


def _passes(usage: dict) -> list[dict]:
    return [
        item
        for item in usage.get("iterations") or []
        if isinstance(item, dict) and item.get("type") in PASS_TYPES
    ]


def _real_size(usage: dict) -> int:
    passes = _passes(usage)
    return _occupancy(passes[-1]) if passes else _occupancy(usage)


def _is_response(record: dict, accept_sidechain: bool) -> bool:
    if record.get("type") != "assistant":
        return False
    if record.get("isSidechain") and not accept_sidechain:
        return False
    message = _message(record)
    return bool(message.get("usage")) and message.get("model") != SYNTHETIC_MODEL


def _is_blocking_notice(record: dict) -> bool:
    message = _message(record)
    if record.get("type") != "assistant" or message.get("model") != SYNTHETIC_MODEL:
        return False
    text = json.dumps(message.get("content"), ensure_ascii=False).lower()
    return "prompt is too long" in text


def _read_records(path: Path) -> list[tuple[dict, int]]:
    with open(path, "rb") as handle:
        handle.seek(0, os.SEEK_END)
        size = handle.tell()
        start = max(0, size - TAIL_BYTES)
        handle.seek(start)
        chunk = handle.read()
    lines = chunk.decode("utf-8", errors="replace").split("\n")
    if start > 0:
        lines = lines[1:]
    records = []
    for line in lines:
        if not line.strip():
            continue
        try:
            record = json.loads(line)
        except json.JSONDecodeError:
            continue
        if isinstance(record, dict):
            records.append((record, len(line)))
    return records


def _parse_window(value) -> int | None:
    if value is None or isinstance(value, bool):
        return None
    if isinstance(value, (int, float)):
        number = float(value)
    else:
        text = str(value).strip().lower()
        if not text or text == "auto":
            return None
        try:
            if text.endswith("m"):
                number = float(text[:-1]) * 1_000_000
            elif text.endswith("k"):
                number = float(text[:-1]) * 1_000
            else:
                number = float(text)
                if 100 <= number <= 1000:
                    number *= 1000
        except ValueError:
            return None
    if math.isnan(number) or number < MIN_CONFIGURED_WINDOW:
        return None
    return int(number)


def _settings_value(cwd: str | None, key: str):
    paths = [Path.home() / ".claude" / "settings.json"]
    if cwd:
        project = Path(cwd) / ".claude"
        paths += [project / "settings.json", project / "settings.local.json"]
    value = None
    for path in paths:
        try:
            data = json.loads(path.read_text(encoding="utf-8-sig"))
        except (OSError, ValueError):
            continue
        if isinstance(data, dict) and data.get(key) is not None:
            value = data[key]
    return value


def _compact_threshold(model_window: int, environ, cwd: str | None) -> int:
    configured = _parse_window(environ.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW"))
    if configured is None:
        configured = _parse_window(_settings_value(cwd, "autoCompactWindow"))
    window = min(model_window, configured) if configured else model_window
    effective = window - SUMMARY_BUFFER
    threshold = effective - COMPACT_BUFFER
    percent = _number(environ.get("CLAUDE_AUTOCOMPACT_PCT_OVERRIDE"))
    if percent is not None and 0 < percent <= 100:
        threshold = min(int(effective * percent / 100), threshold)
    return threshold


def _blocking_limit(model_window: int, environ) -> float:
    # The switch exists for tests that must reproduce the stuck turn without the variable.
    if environ.get("PHANTOM_GUARD_ASSUME_OVERRIDE") == "1":
        return math.inf
    value = _number(environ.get("CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE"))
    if value is not None and value > 0:
        return value
    return model_window - SUMMARY_BUFFER - BLOCKING_BUFFER


def analyse_transcript(
    path: Path, accept_sidechain: bool, environ, cwd: str | None, now: float
) -> dict:
    """Classify the last response of one transcript as phantom or genuine."""
    report: dict = {"path": str(path)}
    try:
        report["age_s"] = round(now - path.stat().st_mtime, 1)
        records = _read_records(path)
    except OSError as exc:
        report["error"] = repr(exc)
        return report

    last_kind = None
    for record, _ in reversed(records):
        if record.get("isSidechain") and not accept_sidechain:
            continue
        kind = record.get("type")
        if kind == "assistant" and _message(record).get("model") == SYNTHETIC_MODEL:
            continue
        if kind in ("user", "assistant"):
            last_kind = kind
            break
    report["ready"] = last_kind == "user"

    last = None
    for index in range(len(records) - 1, -1, -1):
        if _is_response(records[index][0], accept_sidechain):
            last = index
            break
    if last is None:
        report["verdict"] = "no response"
        return report

    # One API response is persisted as several records, one per content block, and results of
    # parallel tool calls can be written between them, so the records are matched by id.
    message_id = _message(records[last][0]).get("id")
    if message_id:
        group = [
            record
            for record, _ in records
            if record.get("type") == "assistant" and _message(record).get("id") == message_id
        ]
    else:
        group = [records[last][0]]
    after = records[last + 1:]

    usages = [_message(record).get("usage") or {} for record in group]
    counted = [usage for usage in usages if usage.get("iterations")]
    final = counted[-1] if counted else usages[-1]
    passes = _passes(final)
    calls = sum(
        1
        for record in group
        for block in _message(record).get("content") or []
        if isinstance(block, dict) and block.get("type") == "server_tool_use"
    )
    if len(passes) >= 2:
        multi = True
        real = _occupancy(passes[-1])
        shown = _occupancy(final)
    elif not final.get("iterations") and calls:
        # An interrupted response keeps only the usage of its first pass; every server tool
        # call adds one more pass over the same context.
        multi = True
        real = _occupancy(usages[0]) + calls * ADVISOR_RESULT_ALLOWANCE
        shown = (calls + 1) * real
    else:
        multi = False
        real = shown = _occupancy(final)

    tail = sum(size for _, size in after) // 4
    largest = max(
        (
            _real_size(_message(record)["usage"])
            for record, _ in records
            if _is_response(record, accept_sidechain)
        ),
        default=0,
    )
    # A 200k window can never hold more than 200k, so a larger real size proves a 1M window.
    model_window = LARGE_WINDOW if max(largest, real) > SMALL_WINDOW else SMALL_WINDOW
    threshold = _compact_threshold(model_window, environ, cwd)
    blocking = _blocking_limit(model_window, environ)
    phantom = multi and real + tail + SAFETY_MARGIN < threshold
    stuck_after_veto = shown + tail >= blocking - BLOCKING_MARGIN
    report.update(
        {
            "multi": multi,
            "passes": len(passes),
            "server_tool_calls": calls,
            "real": real,
            "shown": shown,
            "tail": tail,
            "window": model_window,
            "threshold": threshold,
            "blocking": None if math.isinf(blocking) else int(blocking),
            "blocking_notice": any(_is_blocking_notice(record) for record, _ in after),
            "phantom": phantom and not stuck_after_veto,
        }
    )
    if phantom and stuck_after_veto:
        report["verdict"] = "phantom, but the blocking check would stop the turn"
    else:
        report["verdict"] = "phantom" if phantom else "genuine"
    return report


def _candidates(hook_input: dict) -> list[tuple[Path, bool]]:
    transcript = hook_input.get("transcript_path")
    if not transcript:
        return []
    main = Path(transcript)
    pattern = glob.escape(str(main.with_suffix("") / "subagents")) + os.sep + "**" + os.sep
    agent_id = hook_input.get("agent_id")
    if agent_id:
        found = glob.glob(pattern + f"agent-{glob.escape(str(agent_id))}.jsonl", recursive=True)
        return [(Path(found[0]), True)] if found else []
    found = glob.glob(pattern + "agent-*.jsonl", recursive=True)
    return [(main, False)] + [(Path(item), True) for item in found]


def decide(hook_input: dict, environ, now: float) -> tuple[bool, dict]:
    """Return (allow, report) for one PreCompact call."""
    report: dict = {
        "trigger": hook_input.get("trigger"),
        "agent_id": hook_input.get("agent_id"),
        "input_keys": sorted(hook_input),
        "targets": [],
    }
    if hook_input.get("trigger") != "auto":
        report["reason"] = "not an auto compaction"
        return True, report

    cwd = hook_input.get("cwd")
    precise = bool(hook_input.get("agent_id"))
    analyses = []
    for path, sidechain in _candidates(hook_input):
        try:
            age = now - path.stat().st_mtime
        except OSError:
            continue
        if not precise and age > RECENT_SECONDS:
            continue
        analyses.append(analyse_transcript(path, sidechain, environ, cwd, now))
    report["targets"] = analyses

    ready = [item for item in analyses if item.get("ready") and "age_s" in item]
    if ready and not precise:
        freshest = min(item["age_s"] for item in ready)
        ready = [item for item in ready if item["age_s"] <= freshest + FRESH_SECONDS]
    for item in ready:
        item["considered"] = True
    if not ready:
        report["reason"] = "no transcript is about to send a request"
        return True, report
    if any(item.get("blocking_notice") for item in analyses):
        report["reason"] = "a blocking notice already appeared, the compaction must run"
        return True, report
    if not all(item.get("phantom") for item in ready):
        report["reason"] = "a candidate context is genuinely full or unclear"
        return True, report

    worst = max(ready, key=lambda item: item.get("real", 0))
    report["reason"] = "phantom"
    report["message"] = (
        "Фантомный автокомпакт отменён: ответ с серверным инструментом (например, советником) "
        f"Claude Code посчитал как ~{_fmt(worst['shown'])} токенов, а реальный контекст "
        f"~{_fmt(worst['real'])} при пороге {_fmt(worst['threshold'])}. "
        "На следующем ответе счётчик выправится."
    )
    return False, report


def _log(entry: dict, environ) -> None:
    path = Path(environ.get("PHANTOM_GUARD_LOG") or DEFAULT_LOG)
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        if path.exists() and path.stat().st_size > LOG_MAX_BYTES:
            path.replace(path.with_name(path.name + ".1"))
        with open(path, "a", encoding="utf-8") as handle:
            handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
    except OSError:
        pass


def main() -> int:
    hook_input = _read_hook_input()
    try:
        allow, report = decide(hook_input, os.environ, time.time())
    except Exception as exc:
        allow, report = True, {"reason": "error", "error": repr(exc)}
    report.update(
        {
            "time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
            "session_id": hook_input.get("session_id"),
            "decision": "allow" if allow else "veto",
        }
    )
    _log(report, os.environ)
    if allow:
        return 0
    sys.stderr.buffer.write((report["message"] + "\n").encode("utf-8"))
    sys.stderr.buffer.flush()
    return 2


if __name__ == "__main__":
    raise SystemExit(main())

Registration:

{
  "env": { "CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE": "10000000" },
  "hooks": {
    "PreCompact": [
      { "matcher": "auto",
        "hooks": [ { "type": "command", "command": "python /path/to/precompact-phantom-guard.py", "timeout": 30 } ] }
    ]
  }
}

</details>

Test. Headless claude -p on 2.1.268 with CLAUDE_CODE_DISABLE_1M_CONTEXT=1 (200k window: compaction threshold 167,000, blocking limit 177,000), ~120k real context from Read results, then one advisor call:

| Run | Setup | Result |
|---|---|---|
| P1a | no hook, the whole context in the last user message | level=blocked, "fixed prefix > threshold — compaction cannot help", synthetic "Prompt is too long" at ~130k real of 200k |
| P1 | no hook | phantom auto-compaction, preTokens 242,313 vs 121k real |
| P2 | hook veto, no env override | exit 2 → "Reactive compact skipped" → synthetic "Prompt is too long" (blocking_limit) |
| P3 | hook + env override | exit 2, no compaction, no block; the next response reports 121,543 in one iteration and the turn completes |
| P4 | hook + env override, context grown by single-iteration turns (auto-compact window 130k) | two genuine compactions, both allowed |
| P5 | hook + env override, a subagent calls the advisor (window 150k) | no agent_id in the input; the hook found the subagent transcript and vetoed; the subagent completed |

Replay against the 18 recorded auto-compactions (every session transcript cut at the compaction start): 16 of 17 phantoms vetoed, the genuine one allowed. The one miss was a parallel subagent with a normal response that had become ready 1.5 s earlier, so the hook chose to allow. Without the env override only the 3 in-band cases were vetoed.

Fix suggestion, same as earlier in this thread: compute occupancy from the last type:"message" entry of usage.iterations — the API reference says "Calculate the context window size from the last message entry" — in Gm, in the blocking check and in the context indicator. Gpe already does exactly that.

polisherm · 22 days ago

I've confirmed this is already fixed on 2.1.278 (Windows), in both Claude Desktop and the CLI.
The fix is listed in the 2.1.273 changelog:

Fixed the context meter and auto-compact counting advisor-tool turns at roughly twice their real context size, which made auto-compact fire at about half the real window

In Claude Desktop with Sonnet 5 (1M), I called the advisor at 92% of real context.
Auto-compact did not fire, and the context meter stayed at 92%.