Facade cuma dipakai generically oleh satu caller (Action::Compact); auto-loop tetap butuh kontrol per-stage sendiri. Selaras dengan prinsip "No DI" di CLAUDE.md. dedup::collapse juga diubah mengembalikan (Vec<ChatMessage>, bool) supaya caller tahu ada perubahan tanpa perlu ChatMessage: PartialEq. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
15 KiB
Context & Compaction Overhaul — Design
Status: Approved, pending implementation plan
Date: 2026-07-16
Scope: replaces src/app/runtime/shortsend.rs; touches src/app/runtime/actions/mod.rs,
src/view/status.rs, src/model/settings.rs, src/app/subagent/division.rs, Cargo.toml
Context
The existing conversation-compaction system (shortsend.rs, 129 lines) only acts once the
context is already close to the model's window limit, and has accumulated inconsistencies
found during a codebase audit:
- Three different token-count heuristics for the same job:
/3insideshortsend::shape_messages,/4in the auto-compact loop (actions/mod.rs~line 1146),/4again in the live status bar (view/status.rs:68). - Manual
/compact(Action::Compact,actions/mod.rs:547-563) passesclient: Nonebecauseapply_actionis synchronous, so it never gets LLM summarization — it always falls back to the bare"[prior conversation compacted]"placeholder, unlike automatic mid-turn compaction (Some(&tc.client), line 1160). Undocumented asymmetry between the two trigger paths. context_windowresolution (model_roles.values().find(...).and_then(...).unwrap_or(...)) duplicated three times (Action::Compact,spawn_turn,view/status.rstwice).- No repeated-tool-call dedup: reading the same file (or running the same grep) twice in a session keeps both full copies in context forever, until compaction eventually drops the older one wholesale along with everything else from that period.
- No per-result compression: a single large tool output (a big
bashlog, a largegrepresult) is stored verbatim even when most of it is redundant or low-value. - Zero test coverage on
shortsend.rs.
Separately, research into three real, permissively-licensed open-source projects
(rtk-ai/rtk, Apache-2.0; headroomlabs-ai/headroom, Apache-2.0; JuliusBrussee/caveman,
MIT — verified via gh api for authenticity/license, and by cloning and reading source, not
taken from marketing blog posts) surfaced techniques worth reimplementing natively:
- rtk: generic line-scan compression (strip comment/blank runs, brace-depth collapse of
function bodies, importance-ranked truncation ending in an unambiguous
[N more lines]marker — their own regression tests show a comment-shaped marker confuses the LLM into retry-looping) plus structured per-toolchain parsing (e.g.cargo --message-format=jsonbucketed into errors/warnings, boilerplate lines dropped). - headroom: per-content-type compressors — logs (classify lines by level/stack-trace/ summary, score, keep highest-value lines + surrounding context, adaptive cap), grep results (group by file, score matches, cap globally and per-file), JSON (keep all structural tokens — keys, brackets, colons — drop or shrink long low-entropy string values, keep short values and UUID/hash-shaped high-entropy ones).
- caveman: a pure prompt/persona instruction (no algorithm) that tells the model to write tersely — drop articles/filler/hedging, keep code/commands/errors verbatim — with an explicit carve-out that disables terseness for destructive-op confirmations and security warnings. This compresses output tokens, a different axis from everything else in this design, which compresses input context.
This is a from-scratch reimplementation of the underlying ideas, not a port — no code is copied from any of the three projects.
Goals
- One unified, always-on pipeline that keeps context lean from turn 1, not just once near the limit.
- Deduplicate repeated tool calls: an older copy of a tool result superseded by an identical later call (same tool name + same arguments) is replaced with a placeholder, for read-only tools only.
- Compress large individual tool results (logs, JSON, generic text) at capture time, above a size floor.
- Fix the three known inconsistencies (token heuristic, manual/auto asymmetry,
context_windowduplication). - Optional, off-by-default "concise mode" system-prompt toggle for terser model output.
- Full inline test coverage per repo convention.
Non-goals
- Not adding a runtime dependency on
rtk,headroom, orcavemanthemselves (as a binary, proxy, or crate) — everything is implemented natively in Rust inside zesdex. - Not building rtk's per-toolchain structured parsers (
cargo --message-format=jsonre-invocation, etc.) — too invasive for a general-purposebashtool that runs arbitrary commands zesdex doesn't control the flags of. Only the generic line-scan/log/JSON layer is built. - Not switching to an exact per-provider tokenizer —
tiktoken-rs(BPE, cl100k_base/ o200k_base) is an approximation good enough for the 85%/95% budget thresholds; it is not used for billing-accurate counts. caveman-compress-style memory-file rewriting (the LLM-round-trip variant of caveman) is out of scope — only the pure-prompt persona mechanism is adopted.
Architecture
Replace src/app/runtime/shortsend.rs with src/app/runtime/context/:
context/
mod.rs — module registration only, no facade (see below)
tokens.rs — unified token counting (tiktoken-rs)
dedup.rs — cross-call tool-result deduplication
squash.rs — per-result compression (log/json/generic), applied at
tool-result construction time, upstream of prepare()
shaping.rs — budget-based drop + LLM summarize (renamed shortsend logic)
window.rs — shared context_window resolution
tokens.rs
pub fn count_tokens(text: &str) -> usize
pub fn count_message_tokens(msg: &ChatMessage) -> usize
Backed by tiktoken-rs (new dependency, pure Rust, embedded BPE vocab, no network calls at
runtime), using o200k_base. Replaces all three existing heuristic call sites: shortsend's
internal /3, the auto-loop's /4 (actions/mod.rs ~1146), and status.rs:68's /4.
dedup.rs
pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool)
The bool is true iff at least one message was replaced with a placeholder — callers use
it to decide whether the result is worth persisting/announcing, without needing ChatMessage
to implement PartialEq (it doesn't today, and adding it purely to diff whole message lists
would be needless surface area for what collapse already knows precisely mid-walk).
Flow: walk messages, pair each Role::Tool message to its originating ToolCall via
tool_call_id. Key = (function.name, sha256(canonical_json(function.arguments))) (sha2
is already a dependency). Track the last index seen per key. For any earlier occurrence of a
key whose tool name is in the read-only set, replace that earlier Tool message's content
with a short placeholder ("[duplicate result — superseded by a later identical call, see below]"); the assistant's tool-call entry (name + arguments) is left untouched, so the
action/audit trail stays intact. Mutating tools are never touched, even with identical
arguments, because call order and repetition can be semantically meaningful (e.g. retrying a
flaky bash command).
Read-only classification reuses subagent::division::tool_scope::READ_TOOLS
(src/app/subagent/division.rs:21) rather than a new list — that const is made pub for
this purpose. It already enumerates exactly the read-only tool set (read, grep, glob,
search, seqthink, recall, lsp_*, read_findings).
Runs every turn, unconditionally, before token counting — not gated on should_shape.
squash.rs
pub fn apply(tool_name: &str, output: &str) -> String
read is exempted entirely, always passed through unchanged regardless of size: its output
must stay byte-exact because the agent relies on it for exact-match edits afterward, and a
squashed view of a JSON config file (or any file whose content happens to parse as JSON)
would otherwise be silently altered. Size floor for every other tool: outputs under 1500
bytes pass through unchanged (compression only pays off on large output, and touching small
results risks losing detail with no token benefit). Above the floor, dispatch by content
shape:
squash_json(&str) -> String— hand-rolled JSON tokenizer; structural tokens (keys, brackets, colons, commas, booleans, null) always kept; string values kept if ≤20 chars or high-entropy (Shannon entropy ≥0.85 bits/char, catches UUIDs/hashes/paths — same threshold headroom uses), otherwise replaced with"…"in place; array elements past the first 3 compressed harder (values elided regardless of length/entropy). Applied whenserde_json::from_stron the output succeeds.squash_log(&str) -> String— line classifier (error/fail/warn/info/debug/trace by keyword + stack-trace-frame detection) → score (level_score {1.0 error/fail, 0.5 warn, 0.1 info, 0.05 debug/trace} + 0.3 if stack-trace-frame + 0.4 if summary-shaped line) → keep up to 20 highest-scored error lines, up to 10 highest-scored warning lines, all summary lines, plus a ±2-line context window around each kept line → single[N lines omitted]marker for drops (not comment-shaped, per rtk's own finding on LLM confusion). Applied when the output isn't valid JSON and has ≥3 lines matching error/warn/stack-trace patterns.squash_generic(&str, budget) -> String— importance-ranked truncation: keeps the first 10 and last 10 lines plus any line matching a small "looks important" heuristic (non-blank, not a byte-for-byte repeat of the immediately preceding line), single[N lines omitted]marker for the rest, capped tobudgetbytes overall (budget= the 1500-byte squash floor doubled, i.e. 3000 bytes, chosen so the fallback path still yields a real reduction on anything that triggered it). Fallback for anything that isn't JSON or log-shaped.
Called once, at the single tool-result construction site
(actions/mod.rs:1420, let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output);) — output is passed through squash::apply(&tool_name, &output) before being
wrapped. Runs before the result is ever archived or pushed into msgs, so compression is
permanent and applies uniformly whether or not compaction ever triggers.
shaping.rs
Unchanged behavior from today's shortsend.rs (hysteresis should_shape, 70%-budget
newest-first retention, LLM summarization of dropped messages), moved as-is into this file
and updated to source token counts from tokens.rs instead of its own heuristic.
window.rs
pub fn resolve(app_config: &AppConfig, settings: &Settings) -> usize
Replaces the three duplicated model_roles.values().find(...).and_then(...).unwrap_or(...)
blocks in Action::Compact, spawn_turn, and view/status.rs (×2).
mod.rs
No facade function — just pub mod dedup; pub mod shaping; pub mod squash; pub mod tokens; pub mod window;. dedup, shaping, and tokens are called directly from each call site
(the auto-loop and Action::Compact), matching CLAUDE.md's "No DI — modules call ...
directly" convention rather than introducing an orchestration layer that only one of the two
callers would use generically (the auto-loop already needs per-stage control today — it
inspects should_shape itself to decide whether to emit TurnEvent::Compacted — and would
have to unpack a facade's result anyway).
Data flow (per turn)
- Tool executes → raw
output: String. squash::apply(tool_name, &output)— compress if over the size floor (readexempted).- Wrapped into
ChatMessage::tool_result(...), archived, pushed tomsgs. - Once per loop iteration:
dedup::collapse(&msgs)(always) → sumtokens::count_message_tokensover the result →shaping::should_shape→ conditionallyshaping::shape_messages. - Result pushed as
TurnEvent::Compactedif dedup changed anything or shaping triggered, consumed on the main thread to updateSessionRuntime.messages.
Fixing the manual/auto asymmetry
Action::Compact (actions/mod.rs:547) currently runs synchronously inside apply_action
and can't block on an LLM call. Fix: make it spawn a background std::thread::spawn — the
same pattern spawn_turn already uses (actions/mod.rs:694) — that runs dedup::collapse
then unconditionally shaping::shape_messages(.., force=true, Some(&client)) and reports back
via TurnEvent::Compacted, identical to the automatic path. The toast sequence becomes
"Compacting…" immediately (optimistic, non-blocking) then "History compacted" when the
TurnEvent arrives. This gives manual /compact real LLM summarization instead of always
falling back to the placeholder.
Concise mode (separate from the context/ module)
Settings(src/model/settings.rs) gainspub concise_output: bool, defaultfalse, with#[serde(default)]for backward-compatible deserialization of existingsettings.jsonfiles (matching the existinghive_mind_node_timeout_msprecedent in the same file).- When
true,run_agent_turn's system-prompt assembly (actions/mod.rs:930-936) appends a fourth section tosystem_text: a terse-writing instruction (persona-prompt only, no algorithm — drop articles/filler/hedging/pleasantries, keep code/commands/error text byte-exact) with an explicit carve-out disabling terseness for destructive-operation confirmations and security-relevant warnings, mirroring caveman's own "Auto-Clarity" safety exception. - No UI toggle is in scope for this pass — confirmed no such mechanism exists today for any
boolean
Settingsfield (review_enabled,session_archive_enabled,lsp_auto_provisionare all hand-edited insettings.json, same as this one will be).
New dependency
tiktoken-rs — pure Rust, embedded BPE vocab (cl100k_base/o200k_base), no network calls
at runtime, MIT/Apache-2.0 dual-licensed. Added to Cargo.toml.
Testing
Inline #[cfg(test)] mod tests per repo convention, one per new file:
dedup.rs: same tool+args → older result replaced; different args → no-op; mutating tool with identical args → both kept in full; unmatchedtool_call_id(malformed history) → no panic, treated as unpaired.squash.rs: JSON input under/over the size floor; JSON with long low-entropy string values gets them elided while short/UUID-shaped values survive; log input with error/warn lines keeps highest-scored lines and emits exactly one[N lines omitted]marker; generic text keeps first/last N lines.tokens.rs: known-string token counts against fixed expected values; empty string → 0.shaping.rs: port the behavioral cases implied by today's hysteresis logic (85% trigger when not previously shaped, 95% once shaped) plus budget-drop ordering.window.rs: role match resolves to the role'scontext_window; no match falls back todefault_context_window.
Migration
- Delete
src/app/runtime/shortsend.rs; all three call sites (actions/mod.rsauto-loop,Action::Compact, and the module path itself) updated tocontext::. view/status.rsswitches its live token display totokens::count_tokens, so the status bar finally matches what compaction measures internally.