Skip to content

[BUG] Proactive context expansion injects stale CCR contexts in fast agentic sessions #709

Description

@jocel1

Problem

In fast agentic sessions (automated tool calls, minimal human wait between turns), the proactive context-expansion path re-injects compressed content from tasks that are long finished. The staleness gate in ContextTracker.analyze_query() is wall-clock only (max_context_age_seconds = 300). When many compressing turns happen within a few minutes, a context that was compressed dozens of turns ago still passes the 5-minute freshness check and gets surfaced as [Proactive Context Expansion - relevant to your query], even though the conversation has fully moved on.

The tracker already stores a turn_number on every CompressedContext and already receives current_turn in analyze_query(), but neither is used to gate or decay relevance — only wall-clock age is.

Expected Behavior

Proactive expansion should only inject content that is recent relative to the current conversational position, not just recent in wall-clock time. A context from many compressing-turns ago should not be considered fresh regardless of its timestamp.

Actual Behavior

[Proactive Context Expansion] blocks appear in the active context containing content from much earlier, unrelated tasks.

First-hand reproduction (this is not second-hand): during a single investigation session on this very file, headroom/ccr/context_tracker.py was re-injected 5+ times as a "relevant" proactive expansion across turns whose actual subject had nothing to do with it. The keyword overlap (context_tracker, proactive, expansion) kept matching, the wall-clock age stayed under 300s, and nothing else stopped it. The injected block carried the exact provenance string produced at headroom/ccr/context_tracker.py:485:

{original_item_count} items compressed in turn {turn_number}

i.e. content from a turn far in the conversational past, re-surfaced verbatim.

Root Cause

headroom/ccr/context_tracker.py, analyze_query() — wall-clock age is the only freshness signal:

# context_tracker.py:247
age = now - context.timestamp
if age > self.config.max_context_age_seconds:   # :87  → 300.0 (5 minutes), wall-clock only
    continue
...
# context_tracker.py:255-256
age_factor = 1.0 - (age / self.config.max_context_age_seconds) * 0.5   # max 50% penalty even at 4m59s
relevance *= age_factor
  • current_turn is received and stored (context_tracker.py:234, self._current_turn = current_turn) but never used to gate or decay.
  • context.turn_number is stored on every compressed context but never read in analyze_query().
  • The call site passes the turn correctly: analyze_query(user_query, self._turn_counter, workspace_key=...) at headroom/proxy/handlers/anthropic.py:1354-1357.

Note on the turn counter: self._turn_counter is incremented only on turns that actually compress content (anthropic.py:1309, gated on injector.has_compressed_content), not on every API turn. So "turn distance" here measures elapsed compressing turns — a strong staleness signal, since a context 10+ compressions deep is almost always from a different task.

Environment

  • headroom run as a local HTTP proxy (ANTHROPIC_BASE_URL=http://localhost:8787)
  • Claude Code CLI as the upstream client
  • Fast agentic sessions: many automated tool calls per minute, minimal human turn cadence
  • HEAD e81c96ae (Merge PR docs: explain Claude MCP usage attribution #699)
  • Reproducible whenever the compressing-turn rate stays high within the 300s window

A proposed fix (turn-distance gate + combined decay) follows as a comment below.

Metadata

Metadata

Assignees

No one assigned

    Labels

    MediumDegradation, but still functional

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions