Skip to content

Reversible context compression (ctxzip): 60-95% fewer tokens on bulky tool outputs, losslessly#241

Open
initializ-mk wants to merge 16 commits into
mainfrom
feat/ctxzip-compression
Open

Reversible context compression (ctxzip): 60-95% fewer tokens on bulky tool outputs, losslessly#241
initializ-mk wants to merge 16 commits into
mainfrom
feat/ctxzip-compression

Conversation

@initializ-mk

Copy link
Copy Markdown
Contributor

What

Integrates ctxzip v0.1.0 — reversible, structure-aware context compression — into the agent loop. Bulky tool outputs (JSON arrays, logs, grep results) are compressed before reaching the LLM; everything dropped is stored in a durable local bbolt store behind an inline <<ctxzip:HASH ...>> marker and retrievable via the new context_expand tool. Lossy on the wire, lossless end-to-end.

Off by default. Enable via compression.enabled: true in forge.yaml, FORGE_COMPRESSION=true, forge run --compression, or the new init-wizard step.

Architecture — three seams (forge-core/compress/)

  1. AfterToolExecHook — compresses tool output once, at production time, before it enters Memory. Compressed bytes never change afterwards, so the conversation prefix stays byte-stable and provider prompt caches keep hitting. Registered after guardrail/redaction hooks; error results and small outputs stay verbatim.
  2. WrapClientllm.Client decorator below the FallbackChain (covers retries + compactor calls) compressing the live zone of each request. Deterministic across turns: relevance query pinned to the first user message, never the latest turn.
  3. ExpandToolcontext_expand(hash) builtin; the loop executes it like any other tool, no retrieval machinery needed. Tolerates imperfect hashes (whole markers, truncated hex → unique-prefix resolution against recently emitted markers).

A runtime-owned system directive is appended when compression is on, so every skill's agent knows what markers are and when to expand — skill authors need zero awareness.

Provider prompt-cache hints (ClientConfig.PromptCaching)

Gated by compression.cache_hints (defaults to enabled):

  • anthropic: cache_control: ephemeral breakpoints on the last tool definition + system block (block-form system only when on — wire format byte-identical to today when off, test-asserted). Also applies on the aws_sigv4 Bedrock-passthrough path.
  • openai/gemini: stable prompt_cache_key derived from (model, system, tool names).

Observability

  • context_compressed / context_expanded audit events (via EmitFromContext — correlation_id/task_id/seq/signing like every other event) carrying per-event figures plus running totals.
  • invocation_complete gains compression_saved_tokens_total, compression_count, expansion_count — per-invocation, keyed by correlation ID so concurrent tasks never cross-contaminate.

Config & CLI surfaces

compression:
  enabled: true
  keep_patterns: [CrashLoopBackOff, PAYMENT_DECLINED]  # domain never-drop vocabulary
  # store_path (.forge/ctxzip.db) / ttl (30m) / min_tool_output_chars (2048) / cache_hints
  • forge run --compression[=false] — tri-state override (absent = yaml/env decide)
  • forge serve --compression[=false] — forwarded to the daemon
  • forge init --compression + a new Context Compression TUI wizard step

Live-tested

Hardened against a real gpt-4o agent over a 150-pod fixture (several commits exist because live testing found the failure modes — expand/compress tail-chase, hash transcription, store-dir creation):

  • grep output compressed 1,397 → 51 tokens (96%) with the one CrashLoopBackOff pod kept verbatim
  • model called context_expand unprompted, hash transcribed correctly, got intact lines back, answered with the exact error (OOMKilled 512Mi)
  • surgical sessions correctly report compression_count: 0 — compression is insurance against bulk, not a tax on every call

Notes for reviewers

  • Fail-open everywhere: store init failure, compression error, or inflation all fall back to uncompressed originals.
  • Token figures in audit fields are tokenizer estimates (documented); billed truth stays in llm_call.input_tokens.
  • Known follow-ups (intentionally out of scope): compression-aware tool limits (grep's internal 50-line default truncates upstream of the reversible layer); grep_search returning "(no matches found)" for a nonexistent file; tools: in forge.yaml not registering builtins (banner is cosmetic).

🤖 Generated with Claude Code

initializ-mk and others added 9 commits July 3, 2026 16:34
…ompt-cache hints

Wires github.com/initializ/ctxzip into the agent loop as an opt-in feature
(compression.enabled in forge.yaml, or FORGE_COMPRESSION=true). Bulky tool
outputs and conversation content are compressed before reaching the LLM;
everything dropped is stored in a durable local bbolt store (.forge/ctxzip.db)
behind a <<ctxzip:HASH>> marker, retrievable via the new context_expand tool —
lossy on the wire, lossless end-to-end.

New package forge-core/compress:
- AfterToolExecHook — compresses tool output once at production time, before
  it enters Memory, so historic bytes never change and provider prompt caches
  keep hitting. Registered after guardrail/redaction hooks; error results and
  small outputs are left verbatim.
- WrapClient — llm.Client decorator compressing the live zone of every
  outbound request (frozen prefix + recent turns forwarded byte-identical).
  Deterministic across turns: the relevance query is pinned to the first user
  message, never the latest turn.
- ExpandTool — context_expand builtin retrieving originals by marker hash;
  registered only when compression is on (memory_get pattern).

Provider prompt-cache hints (ClientConfig.PromptCaching, gated by
compression.cache_hints, defaulting to compression.enabled):
- anthropic: cache_control ephemeral breakpoints on the last tool definition
  and the system block (block-form system only when caching — wire format is
  byte-identical to the previous contract when off). Also applies on the
  aws_sigv4 path, which speaks the same Messages wire format.
- openai: stable prompt_cache_key derived from (model, system, tool names)
  for cache-shard pinning; prefix caching itself is automatic.

Config: CompressionConfig (enabled / store_path / ttl / min_tool_output_chars
/ cache_hints) following the MemoryConfig.LongTerm opt-in pattern, wired in
the runner beside initLongTermMemory. Fail-open: any init error runs the
agent uncompressed.

Tests: hook compression + context_expand round-trip, error/small-output
verbatim guarantees, live-zone vs frozen-prefix boundaries, cross-turn
determinism (the cache-safety property), marker-hash normalization,
anthropic/openai wire-format assertions on and off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…silience, store dir

Fixes from live agent testing (gpt-4o against a 150-pod fixture):

- Never recompress context_expand output, at both seams (hook skips the
  tool by name; client wrapper passes ctxzip SkipNames). Without this the
  loop chased its own tail: the model expanded a marker and the hook
  crushed the expansion straight back into a marker.
- Hash transcription resilience: models truncate or mangle marker hashes
  when copying them into tool calls. The Runtime now remembers emitted
  marker hashes; the expand tool resolves a unique prefix (≥6 chars) on
  exact-miss, and normalizeHash strips a glued ":count" suffix.
- Create the store's parent directory (bbolt creates the file, not the
  dir) — on a fresh project .forge/ doesn't exist and compression failed
  open with "no such file or directory".
- Bump ctxzip to a8b7923→94668f4: line-mode text compression (grep/log
  layout preserved byte-faithfully through the CCR round trip), stop-term
  filtering in line dedup, 12-hex marker hashes.

Live result on "status breakdown + unhealthy pod" over 150 pods:
tool output crushed 1397→51 tokens (96%), model called context_expand
with a correctly-transcribed hash, got intact lines back, and answered
with the exact error (CrashLoopBackOff / OOMKilled 512Mi) — total
session 10,982 input tokens vs 19,799 in the pre-fix run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
compression.keep_patterns lets the agent builder declare a domain
vocabulary of case-insensitive substrings compression must never drop:

  compression:
    enabled: true
    keep_patterns: [CrashLoopBackOff, ImagePullBackOff, OOMKilled]

Threaded through compress.Config into both seams (AfterToolExec hook and
the llm.Client wrapper) as ctxzip Options.MustKeep. Union semantics with
ctxzip's built-in error floor — patterns only ever add protection.

Bumps ctxzip to 304962f, whose defaults also grow k8s state words
(crash/backoff/oomkilled/evicted/unhealthy/degraded) after live testing
showed "CrashLoopBackOff" matched nothing in the original error list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two new audit events make token savings attributable instead of living
only in debug logs:

- context_compressed — fired from both seams (tool_output hook, request
  wrapper) with seam, tool, tokens_before/after, saved_tokens, plus
  running totals (total_saved_tokens / total_compressions /
  total_expansions) so any single event shows the cumulative picture,
  not just the per-call delta.
- context_expanded — fired on every context_expand retrieval with hash,
  hit, bytes and the same running totals; expansions are the cost side
  auditors net against savings.

Events flow through AuditLogger.EmitFromContext, so correlation_id /
task_id / seq are stamped like every other audit event and SIEM
consumers can join savings to invocations. compress stays decoupled via
a Config.Audit callback; nil disables emission. Runtime.Totals() exposes
the process-lifetime snapshot. Token figures are tokenizer estimates
(directionally accurate), not provider-billed counts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
invocation_complete now carries compression_saved_tokens_total,
compression_count, and (when nonzero) expansion_count alongside the
existing input/output token totals, so per-invocation cost rollups show
what compression saved without joining context_compressed events.

Savings are accumulated per correlation ID inside compress.Runtime and
popped once at the response boundary (TakeInvocationTotals), so
concurrent invocations never cross-contaminate — diffing the process-
lifetime totals would have. Fields are present whenever compression is
enabled; zeros mean "on, but nothing was worth compressing". Token
figures remain tokenizer estimates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ker awareness

Marker awareness was living in the test agent's SKILL.md, which does not
scale: every skill author would have to document compression. Compression
is a runtime capability, so the runtime now briefs the model itself —
when compression is enabled, compress.SystemDirective is appended to the
system prompt (same pattern as codeAgentDirective), explaining what
<<ctxzip:...>> markers are, that the visible remainder keeps errors and
representative content, and when/how to call context_expand.

The directive is a constant, keeping the system prompt byte-stable across
turns for provider prompt caches. A guard test pins it to the real tool
name and marker prefix so a rename cannot silently orphan the text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the feat-branch pseudo-version with the immutable release tag —
the forge PR now depends on a stable, reviewable ctxzip version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Compression becomes reachable from every entry point, not just
forge.yaml/env:

- `forge run --compression` / `--compression=false` — tri-state: absent
  leaves yaml/env resolution untouched; explicit values override both by
  setting FORGE_COMPRESSION (same pattern as --model → MODEL_NAME).
- `forge serve --compression[=false]` — forwarded to the forked daemon
  `forge run`, only when explicitly passed.
- `forge init --compression` — non-interactive scaffolding writes a
  commented `compression.enabled: true` block into forge.yaml.
- init TUI wizard — new "Context Compression" step (SingleSelect,
  Enabled/Disabled with explanatory descriptions) between Skills and
  Auth; selection flows through WizardContext.Compression into the same
  forge.yaml block.

Scaffold test covers both directions: --compression writes the block,
default omits it (off by default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New docs/core-concepts/context-compression.md — the feature's home:
problem statement, pipeline diagram, keep-floor layers, configuration
and precedence, provider cache hints, observability, failure posture.

Per the sync-docs mapping, updates ripple to:
- forge-yaml-schema.md — compression block in the full schema plus a
  dedicated reference section with field table
- cli-reference.md — --compression rows in the init/run/serve flag
  tables; wizard step order documented under forge init
- runtime-engine.md — Context Compression section describing the three
  loop seams (hook after guardrails, client wrapper below the fallback
  chain, context_expand tool) and the cache-stability posture
- audit-logging.md — context_compressed / context_expanded event rows;
  invocation_complete row gains the per-invocation compression fields
- tools-and-builtins.md — context_expand in the builtin table plus a
  Context Expansion Tool section (hash tolerance, miss guidance)
- environment-variables.md — FORGE_COMPRESSION
- README.md — Context Compression row in the documentation table
- .claude/skills/forge.md — swept sections 8 (memory), 13 (CLI), 14
  (schema), 17 (audit reference), 19 (docs map); ToC unchanged (no new
  numbered sections)

Link check: 0 broken links across README + 55 docs files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@initializ-mk

Copy link
Copy Markdown
Contributor Author

Requesting changes (posting as a comment — GitHub won't let the author submit a formal Request-Changes review on their own PR). Blocking item: the streaming perInvocation leak below.

Reviewed for correctness and traced the invocation-complete paths + concurrency. Strong PR — fail-open everywhere, careful locking (accumulate under lock → snapshot → emit audit outside lock), the expand-tool-output-never-recompressed tail-chase fix, and the "wire format byte-identical when off" prompt-cache path is test-asserted. Builds clean; compress + llm/providers tests pass. One real bug on the primary path.

🔴 perInvocation leaks (and compression metrics are missing) on the streaming paths

TakeInvocationTotals — which both pops perInvocation[correlationID] and adds the compression_* fields — is called in exactly one place: executeTask (runner.go:1552). But there are three invocation_complete emission sites, and the other two are the sendSubscribe streaming handlers that run their own ExecuteStream loop instead of executeTask:

  • tasks/sendSubscribe (JSON-RPC SSE) → emits at 1296, no Take.
  • REST POST /tasks/sendSubscribe → emits at 1821, no Take.

Both streaming handlers set a correlation ID on ctx (WithCorrelationID) and use the shared compressed client + hooks, so a streaming invocation that compresses does call recordCompression → bumpInvocation, adding a perInvocation[cid] entry. Since only executeTask pops, and perInvocation has no TTL/sweep, every streaming invocation that compresses leaks its bucket permanently. sendSubscribe is a primary A2A mode, so with compression on this grows unbounded over process lifetime.

Same root cause, second gap: the PR advertises invocation_complete gaining compression_saved_tokens_total / compression_count / expansion_count, but those are only added in executeTask — so streaming invocation_complete events don't carry them. The feature is under-delivered on the common path.

Fix (one change closes both): factor the "TakeInvocationTotals(ctx) + populate the three compression fields" block out of executeTask into a helper, and call it at all three EmitInvocationComplete sites (1296, 1565, 1821). Optionally also give perInvocation a size cap / periodic sweep so a missed Take can't leak — but the shared helper is the real fix and also ships the metrics.

🟡 Secondary

  • recent marker map is unbounded — every emitted marker hash is remembered (rememberMarkers) and never evicted. Small strings, but grows for the process lifetime; a cap / LRU would bound it (you only need recent markers for imperfect-hash resolution).
  • bbolt is a single-writer file lock. .forge/ctxzip.db can be opened by only one process; two agents on a shared volume (K8s multi-replica) or forge run + forge serve on the same dir will collide. Fail-open only helps if ccr.NewBoltStore errors rather than blocks on the flock — worth confirming it sets an Open timeout, and documenting the single-writer constraint in the compression doc ("one store per process/replica").
  • Inflation guard is == 0, not <= 0 (hook.go: res.SavedTokens() == 0). If ctxzip can ever return negative savings (inflated output), that guard misses it and you'd record negative savings + apply the inflated bytes. Depends on ctxzip's contract; low risk, but <= 0 is safer.

Note (in the PR's favor)

go.mod is correct — ctxzip is a direct require and bbolt is correctly // indirect (transitive via ctxzip; compress imports ctxzip/ccr, not bbolt). No go mod tidy needed.

Verdict

Request changes for the streaming perInvocation leak + missing metrics — a genuine unbounded leak on the main path and a gap between advertised and actual streaming behavior. The shared-helper refactor is small and fixes both. The bbolt single-writer note and recent cap are worth doing but non-blocking.

…s; bound runtime maps

Addresses the PR #241 review (blocking + secondary items):

- 🔴 Streaming perInvocation leak + missing metrics: TakeInvocationTotals
  was only called in executeTask, but invocation_complete is emitted from
  THREE sites — the tasks/sendSubscribe JSON-RPC SSE and REST streaming
  handlers ran their own emission without popping the bucket, so every
  streaming invocation that compressed leaked its correlation bucket
  permanently AND its invocation_complete lacked the compression fields.
  The pop+populate block is now a shared helper (appendCompressionFields,
  documented as required at every emission site) called from all three.
  Pinned by TestAppendCompressionFields_PopsAndPopulates: fields
  populated, pop is one-shot (no double-count), nil-safe.

- 🟡 Leak backstops: perInvocation is bounded to 1024 buckets with
  oldest-touched eviction (a future missed pop can no longer grow
  unbounded); the recent-marker prefix-resolution set is bounded to 2048
  with oldest-emitted eviction. Both pinned by tests.

- 🟡 Inflation guards tightened from == 0 to <= 0 at both seams — ctxzip
  clamps SavedTokens at zero today, but the guard must not silently apply
  inflated output if that contract ever changes.

- 🟡 bbolt single-writer constraint documented in context-compression.md:
  the store holds an exclusive flock with a 5s open timeout (verified in
  ctxzip's NewBoltStore), so a second process fails open and runs
  uncompressed; each replica should get its own store_path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@initializ-mk

Copy link
Copy Markdown
Contributor Author

Resolved in deb06fd — all four findings addressed:

🔴 Streaming perInvocation leak + missing metrics — fixed with the suggested shared helper. The pop+populate block is now appendCompressionFields(ctx, fields), called at all three EmitInvocationComplete sites (executeTask, JSON-RPC SSE sendSubscribe, REST sendSubscribe). The helper's doc comment names all three sites explicitly so a future fourth emission path knows the contract. Pinned by TestAppendCompressionFields_PopsAndPopulates: fields populated on first call, one-shot pop (second call returns zeros — no double-count if two sites ever fire), nil-safe with compression disabled.

🟡 Leak backstops (took the "optionally also" suggestion): perInvocation is now bounded to 1024 buckets with oldest-touched eviction — a future missed pop degrades to bounded memory instead of an unbounded leak. recent markers bounded to 2048 with oldest-emitted eviction (only recent markers matter for transcription repair; exact hashes still resolve via the store). Both pinned by tests (TestPerInvocationBuckets_Bounded, TestRecentMarkers_Bounded).

🟡 Inflation guards: both seams tightened from == 0 to <= 0. Confirmed ctxzip's SavedTokens() clamps at zero today, so this is defense-in-depth against a future contract change — noted as such in the code comment.

🟡 bbolt single-writer: confirmed ccr.NewBoltStore sets bolt.Options{Timeout: 5 * time.Second} — a second process errors after 5s rather than blocking, so fail-open engages (warning logged, agent runs uncompressed). Documented in context-compression.md § Failure posture: one store per process/replica, give each replica its own store_path.

Gate: build ✅, vet ✅, gofmt ✅, forge-core 32 packages ✅, forge-cli/runtime full suite ✅.

And thanks for the go.mod note — glad the direct/indirect split checked out.

🤖 Generated with Claude Code

initializ-mk and others added 4 commits July 5, 2026 16:58
…g on selection

The wizard advances ONLY on tui.StepCompleteMsg (wizard.go documents
"never check Complete() here"), but the compression step just set
complete=true and returned — selecting an option left the wizard stuck
on the step with Esc as the only exit (found live).

Two fixes, following the house step patterns:
- Update emits StepCompleteMsg on selection (ChannelStep pattern).
- Init resets completion + selector state (SkillsStep pattern) so
  navigating BACK to the step re-prompts instead of stranding the user
  on a done selector that swallows all input.

Regression tests pin both: enter emits StepCompleteMsg and Apply carries
the choice; down+enter selects Disabled; back-navigation resets and
accepts a fresh selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live k8s triage against a real cluster showed compression_count: 0 on
28-37KB kubectl outputs: cli_execute wraps results in a single-line
JSON envelope whose escaped newlines defeat content detection. ctxzip
v0.1.1 compresses large string fields inside JSON-object envelopes,
collapses identifier tokens in line dedup (kubectl tables now crush),
and adds "warn" to the error floor (TYPE=Warning events never dropped).
Measured on the live shape: 7,261 -> 95 tokens (98.7%), anomaly row
kept verbatim. No forge-side code change — the adapter passes envelopes
through ctxzip.Compress already.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live A/B exposed a metric-semantics gap: an invocation whose provider
bill dropped ~31K tokens (73,017 -> 42,105) reported
compression_saved_tokens_total: 1,257. The field counted compression
EVENTS that occurred during the invocation, but savings are realized
per RESEND — the previous invocation's crushed table rode in recovered
history on every one of this invocation's four calls, saving ~5.6K each
time, attributed to nothing.

Accounting fix: rememberMarkers now stores each marker's saved-token
delta (the recent map doubles as the prefix-resolution set); the client
wrapper credits, on EVERY outbound Chat/ChatStream, the deltas of all
markers riding in the request — this call's transforms and history
markers alike. TTL-immune (no store reads), bounded by the existing
marker cap.

invocation_complete now reports:
- compression_saved_tokens_total — realized wire savings (matches the
  provider bill; the number operators expect)
- compression_event_saved_tokens — the old per-event sum (still matches
  the invocation's context_compressed events)

Pinned by TestWireSavings_CompoundPerResend (three resends credit ~3x
the one-time event saving). Docs updated (audit-logging,
context-compression, forge.md knowledge skill).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live run 004: kubectl get pods -A -o json produced 108KB; the loop's
pre-hook truncation cut it mid-JSON-string, which both destroyed data
and broke the envelope so compression bailed — a mangled 12K-token blob
rode through four more LLM calls.

With compression enabled the loop now runs:

  execute -> safety ceiling (16x cap, abs max 4MB) -> hooks
          (guardrails -> compression) -> normal cap -> memory

- The compression hook sees the FULL output; envelopes stay intact and
  crush instead of being destroyed. Guardrails also now scan the full
  output rather than a truncated prefix — strictly more correct.
- The pre-hook safety ceiling bounds pathological outputs so hooks
  never scan unbounded payloads.
- The normal 25%-budget cap still applies to the post-hook result —
  usually a no-op after compression; the context window still wins
  when it isn't.
- Gated by LLMExecutorConfig.DeferToolResultTruncation, set by the
  runner only when compression is on: compression-off deployments keep
  today's order byte-identical (pinned by the existing truncation test).

Bumps ctxzip to v0.1.2: envelopes that still arrive cut mid-string
(safety ceiling, other runtimes) are salvaged — intact prefix
compressed, valid JSON re-emitted, plus a _ctxzip_note telling the
model the tail was destroyed upstream, not offloaded.

Tests: hook sees full 60K output and its compressed replacement reaches
the LLM; post-hook cap applies when nothing shrank; 500K output bounded
by the ceiling before hooks; legacy order unchanged with the flag off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@initializ-mk

Copy link
Copy Markdown
Contributor Author

Update: live-hardening round 2 — envelope compression, honest metrics, compress-then-truncate

Since the review fixes (deb06fd), five more commits landed, each driven by a live k8s-triage run against a real cluster (cli_execute + kubectl, gpt-5.4). Summary of what changed and why.

🔍 Live finding 1: 28–37KB kubectl outputs compressed 0%

cli_execute wraps output in a single-line JSON envelope — {"stdout":"...\n...","stderr":"",...} — whose escaped newlines defeat every content detector (zero physical newlines: not an array, not line-oriented, not prose). Fix (ctxzip v0.1.1): JSON-envelope recursion — large string fields are decoded (real newlines restored), compressed through the normal routing path, and spliced back; output stays valid JSON, untouched fields byte-identical, key order preserved (deterministic). Plus identifier-collapsing line signatures (kubectl rows barely deduped — every pod name is a unique token) and warn added to the error floor (kubectl TYPE=Warning events matched nothing).

🔍 Live finding 2: compression_saved_tokens_total: 1,257 on an invocation that actually avoided ~31K billed tokens

The field counted compression events; savings are realized per resend — compressed history saves its delta on every subsequent LLM call, attributed to nothing. Fix (2e38661→8d2f629): each marker remembers its saved-token delta; the client wrapper credits, on every outbound call, all markers riding in the request. invocation_complete now reports compression_saved_tokens_total as realized wire savings (matches the provider bill) with compression_event_saved_tokens kept for reconciliation against context_compressed events. TTL-immune (no store reads), per-correlation isolation unchanged, compounding pinned by test.

🔍 Live finding 3: a 108KB kubectl get pods -A -o json was destroyed before compression saw it

The loop's pre-hook truncation cut the envelope mid-JSON-string — destroying data and breaking the JSON so compression bailed; the mangled 12K-token blob rode through four more calls. Fix (ed1dd4c): with compression enabled, the loop runs execute → safety ceiling (16× cap, abs 4MB) → hooks (guardrails → compression) → normal cap — the hook sees full output, envelopes stay intact, guardrails now scan the full payload (strictly more correct), and the normal cap applies to the usually-tiny compressed result. Gated by LLMExecutorConfig.DeferToolResultTruncation; compression-off deployments keep today's order byte-identical (existing truncation test untouched). ctxzip v0.1.2 additionally salvages envelopes that still arrive cut mid-string: intact prefix compressed, valid JSON re-emitted, _ctxzip_note telling the model the tail was destroyed upstream, not offloaded.

🐛 Also fixed

TUI compression step hung on selection — never emitted StepCompleteMsg (the wizard's sole advancement path) and didn't reset on back-navigation; both fixed per the ChannelStep/SkillsStep house patterns, pinned by three step tests (eec813c).

📊 Validation — same agent, same cluster, same questions across the series

Run State Invocation 1 billed input Invocation 2 billed input
baseline pre-envelope 41,277 (saved 0) 73,017 (saved 0)
v0.1.1 envelope 22,861 42,105
final + wire metric + defer-truncation 15,072 (saved 7,260) 51,211* (saved 32,809)

Invocation 1: −63% billed input vs baseline, answer quality unchanged (both CrashLoopBackOff pods with restart counts), zero expansions needed. Metrics reconcile: inv 1 wire == event (each marker rode one call); inv 2 wire ≈ 9× event (recovered compressed history × 4 calls — the compounding finding 2 was missing). *Inv 2 includes a 40s cluster API Bad-Gateway retry loop (infra flake; error output correctly kept verbatim).

Session-level: ~38% saved, compression_count/expansion_count honest at both extremes (surgical runs correctly report 0).

Dependency

ctxzip pinned at v0.1.2 (was v0.1.0 at review time; v0.1.1 and v0.1.2 are the findings above).

Docs updated throughout (context-compression.md gained truncation-interaction and single-writer sections; audit-logging reflects the field semantics; /sync-docs mapping honored).

🤖 Generated with Claude Code

staticcheck SA4006: the final pressCompression return of s was never
read — only the emitted message matters for the assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@initializ-mk

Copy link
Copy Markdown
Contributor Author

Reviewed the e2e-fix commits (ed1dd4c compress-then-truncate, 8d2f629 realized wire savings, eec813c TUI hang, cfe7bf9 ctxzip v0.1.1) — correct, well-gated, and tested; no regression to the perInvocation fix (builds clean, defer-truncation test + compress -race + cli compression tests green). Verified the realized-savings change is a dual metric (SavedTokens one-time vs WireSavedTokens per-resend), not a double-credit.

One minor, non-blocking edge case to flag for follow-up:

Post-hook truncation can cut a compression marker mid-string. In deferred mode the size cap is applied to the compressed result by byte offset (loop.go):

if e.deferToolTruncation && len(result) > e.maxToolResultChars {
    result = result[:e.maxToolResultChars] + "\n\n[OUTPUT TRUNCATED ...]"
}

This only fires when the compressed result still exceeds the cap (rare — incompressible or all-keep_patterns content), but when it does the cut can land inside a <<ctxzip:HASH ...>> marker, leaving the model a broken marker it can't expand. The offloaded content is still in the store, just unreachable via that truncated marker.

Suggested fix: truncate at the last complete marker boundary before the cap (or drop a trailing partial marker) so post-hook truncation never corrupts a marker. Low severity given how rarely it triggers, but worth closing.

…n cut

PR #241 review follow-up: in deferred mode the post-hook cap sliced the
compressed result at a byte offset, which could bisect a
<<ctxzip:HASH ...>> marker — leaving the model a corrupted pointer to
content that is still in the store but unreachable through that marker.
Rare (fires only when even the compressed result exceeds the cap), but
wrong.

truncateToolResult now backs the cut up to the marker's start when the
byte cut would land inside one — output carries whole markers or none.
Applied at both deferred-mode cut sites (safety ceiling and post-hook
cap); the legacy non-deferred path is untouched (no compression, no
markers, byte-identical). The marker prefix is pinned as
runtime.CompressionMarkerPrefix (runtime cannot import compress — cycle)
with a guard test asserting it matches ccr.MarkerPrefix so the two can
never drift.

Tests: straddling marker dropped whole, complete marker kept, plain cut
without markers, unterminated marker dropped, plus a loop-level case
where a compression hook's output places a marker straddling the cap —
the LLM never sees a partial marker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@initializ-mk

Copy link
Copy Markdown
Contributor Author

Resolved in 8e0fec6 — the post-hook cut is now marker-aware.

truncateToolResult backs the cut up to the marker's start whenever the byte offset would land inside a <<ctxzip:HASH ...>> marker, so truncated output carries whole markers or none — never a corrupted pointer to content that's still in the store. Applied at both deferred-mode cut sites (the pre-hook safety ceiling too, for the edge where raw tool output echoes marker-like text); the legacy non-deferred path is untouched.

One wrinkle worth noting: the runtime can't import the compress package (compress imports runtime — cycle), so the marker prefix is pinned as runtime.CompressionMarkerPrefix with a guard test in the compress package asserting it equals ccr.MarkerPrefix — the literals can't silently drift.

Pinned by five unit cases (straddling marker dropped whole, complete marker kept, plain cut, unterminated marker, under-limit no-op) plus a loop-level test where a compression hook's output places a marker straddling the cap and the LLM must never see a partial marker. Lint + runtime/compress suites green locally.

🤖 Generated with Claude Code

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.

1 participant