Skip to content

expand: an expand permanently un-compacts its content and costs one prefix flip, undocumented and uncounted; in-place replacement was never built #201

Description

@amiddavid

What the code actually does today, since it is documented nowhere

Two mechanisms were apparently considered when expand was designed. Only one is in the tree, and its cross-turn behaviour is not what the other's rationale assumed.

The in-turn path. The model calls context_guru_expand; the proxy intercepts it (proxy.go:~1650), and expand.Continuation (expand/response.go:69) appends two messages to the upstream request body — the assistant message carrying the tool_use, then a user message with one tool_result block per call holding the original content — then re-invokes upstream, up to maxExpandRounds (3). The content lands at the end, so the cached prefix is untouched during the turn. That part works as intended.

It does not persist as its own turn. On the intercepted path the client never sees the tool_use/tool_result pair — the proxy answers it and returns only the final assistant response. So the next turn the client re-sends its own transcript without the expanded content. The expansion is transient.

What persists is a flag, not the content. offload.MarkKeptVerbatim writes cg:keep:<content-hash>, and thereafter every offloader skips that content (skipReduceisKeptVerbatim). So from the next turn the original message goes upstream in full, at its original position.

The consequence, measured

Turn N sent compacted bytes at that position; turn N+1 sends the full original there. That is a change inside the cached prefix, so it costs a cache-write of the suffix — at ~11.5x a cache read, which is the cost the tail gate exists to avoid everywhere else.

Measured directly (mask, keep_recent: 0, min_tokens: 20, one 4,200-token tool output):

turn 1 sent 73 tokens (compacted)
turn 2 sent 4,200 tokens (full)   gates=map[marker_or_kept_verbatim:1]

So "expand does not hurt the cache" is true within the turn and false across it. The flip happens once per expanded content, on the turn after the expand.

That is arguably correct behaviour — re-compacting would loop the agent straight back into another expand, which is exactly what cg:keep: exists to prevent, and one cache-write is cheaper than an unbounded expand loop. But it is a real cost that is currently undocumented, uncounted, and invisible: no counter distinguishes "prefix flipped because the agent expanded" from any other cache-write, so an operator cannot see it and a benchmark cannot attribute it.

The alternative that was considered and does not exist

replace the marker with the content in place, and delete the expand tool call from the earlier messages.

Nothing in the tree does this. I checked expand's whole exported surface — Inject, HasTool, ResponseCalls, Continuation, Marker, HasPlaceholder, ParseMarkers, Resolve, ToolDefRaw, RepairToolResults, Unavailable, AggregateSSE, Unresolved — and there is no in-place replacement and no stripping of prior expand calls.

There is no switch between the two. inject_expand (auto / always / never) controls only whether the tool is advertised, not how a call is answered.

The one path where content does persist

repairExpandErrors (proxy.go:1296). When interception structurally cannot work — a client tool batched alongside expand, maxExpandRounds exhausted, an event stream that will not reconstruct, a non-Anthropic stream, or a bypassed turn carrying older markers — the client did see the call and answered No such tool available. The proxy then repairs that tool_result on the request side on every later turn, so the content does live in the client's transcript.

Worth being explicit: that is a fallback for a failure, not a mode. It is also the only configuration in which the expanded content survives as its own turn.

What is worth deciding

  1. Document the cross-turn behaviour, since neither docs/how-to/recover-context.md nor docs/design.md says that an expand permanently un-compacts that content or that it costs one prefix flip.
  2. Count the flip. A counter for "prefix flipped because content was expanded" would make it attributable. Note this is the same split-the-ambiguous-counter shape as proxy: an unrecognised usage dialect is indistinguishable from no usage, and degraded silently for 4,015 requests #200: today an expand-induced cache-write is indistinguishable from any other.
  3. If in-place replacement is wanted, the gating signals already exist. It was suggested it should apply only on a cache miss or after a serious compaction — ColdCache / TailOnlyCold already detect a turn whose prefix cache has provably expired, and compaction_resets counts turns where the agent's own compaction moved the boundary. So the condition is expressible with machinery that ships; only the behaviour is missing.

How it was found

Answering a question about which of the two designs is live. The measurement above is from a scratch test, not committed. Related: #199 (a different per-content-versus-per-message mismatch in the replay bypass), #200 (the counter-splitting pattern).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions