Reversible context compression (ctxzip): 60-95% fewer tokens on bulky tool outputs, losslessly#241
Reversible context compression (ctxzip): 60-95% fewer tokens on bulky tool outputs, losslessly#241initializ-mk wants to merge 16 commits into
Conversation
…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>
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; 🔴
|
…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>
|
Resolved in deb06fd — all four findings addressed: 🔴 Streaming 🟡 Leak backstops (took the "optionally also" suggestion): 🟡 Inflation guards: both seams tightened from 🟡 bbolt single-writer: confirmed Gate: build ✅, vet ✅, gofmt ✅, And thanks for the go.mod note — glad the direct/indirect split checked out. 🤖 Generated with Claude Code |
…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>
Update: live-hardening round 2 — envelope compression, honest metrics, compress-then-truncateSince the review fixes (deb06fd), five more commits landed, each driven by a live k8s-triage run against a real cluster ( 🔍 Live finding 1: 28–37KB kubectl outputs compressed 0%
🔍 Live finding 2:
|
| 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>
|
Reviewed the e2e-fix commits ( 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 ( 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- 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>
|
Resolved in 8e0fec6 — the post-hook cut is now marker-aware.
One wrinkle worth noting: the runtime can't import the compress package (compress imports runtime — cycle), so the marker prefix is pinned as 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 |
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 newcontext_expandtool. Lossy on the wire, lossless end-to-end.Off by default. Enable via
compression.enabled: truein forge.yaml,FORGE_COMPRESSION=true,forge run --compression, or the new init-wizard step.Architecture — three seams (
forge-core/compress/)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.WrapClient—llm.Clientdecorator 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.ExpandTool—context_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 toenabled):cache_control: ephemeralbreakpoints 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 theaws_sigv4Bedrock-passthrough path.prompt_cache_keyderived from (model, system, tool names).Observability
context_compressed/context_expandedaudit events (viaEmitFromContext— correlation_id/task_id/seq/signing like every other event) carrying per-event figures plus running totals.invocation_completegainscompression_saved_tokens_total,compression_count,expansion_count— per-invocation, keyed by correlation ID so concurrent tasks never cross-contaminate.Config & CLI surfaces
forge run --compression[=false]— tri-state override (absent = yaml/env decide)forge serve --compression[=false]— forwarded to the daemonforge init --compression+ a new Context Compression TUI wizard stepLive-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):
CrashLoopBackOffpod kept verbatimcontext_expandunprompted, hash transcribed correctly, got intact lines back, answered with the exact error (OOMKilled 512Mi)compression_count: 0— compression is insurance against bulk, not a tax on every callNotes for reviewers
llm_call.input_tokens.grep_searchreturning "(no matches found)" for a nonexistent file;tools:in forge.yaml not registering builtins (banner is cosmetic).🤖 Generated with Claude Code