feat: session-level output dedup - #2937
Conversation
|
Context / gauging interest 👋 Opening this to see whether session-scoped dedup is a direction you'd welcome — happy to iterate on the design or close it if it's not something you want to carry. The problem it targets. RTK's filters compress a single command's output. But a big token sink in agentic use is orthogonal: an agent re-reads the same file or re-runs The approach, kept deliberately low-risk:
The one part I'd especially want your read on: the hook change. To scope by session, Fully tested (unit + an end-to-end integration test) and passing the |
|
Update — added compaction protection. Pushed a follow-up commit closing the one real correctness gap in the original design: dedup assumes the earlier full emission is still in the agent's context, which context compaction can violate (the original output gets summarized away, leaving a stub that points at content no longer present). Two layers now guard this:
Also made Still off by default, still fully tested (unit + integration, incl. an end-to-end |
encode_project_path() didn't replace ':', so on Windows the cwd C:\dev\Projects\RTK encoded to C:-dev-Projects-RTK instead of Claude Code's actual session dir name C--dev-Projects-RTK. The substring filter never matched, causing rtk discover to silently report 0 sessions scanned on every Windows install. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013U46uCFYrLTzSwmKmGKVRZ
Introduces core::session::SessionCtx, resolved once from the --session global flag (injected by the Claude Code hook) or the RTK_SESSION_ID env var, and stashed in a process-global OnceLock. Absent for manual invocations, in which case session-scoped features (output dedup, coming in later phases) no-op and raw output is unchanged. Phase 1 of the session-level output dedup feature. The read side (id()/current()) carries transitional allow(dead_code) until the dedup subsystem consumes it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
process_claude_payload now reads session_id from the PreToolUse payload and splices `--session <id>` into the executed command after the first rtk command word. Session ids are validated ([A-Za-z0-9_-] only) before splicing to prevent shell injection; an absent or unsafe id leaves the command session-less (session-scoped features no-op). Compound commands get the flag on their first rtk segment only; later segments run session-less and degrade safely. Audit logging keeps the clean pre-injection form, so the volatile id stays out of the trail. Phase 2 of session-level output dedup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New session_outputs table (keyed by session_id + content_hash) with dedup_lookup / dedup_insert / dedup_bump on Tracker, plus a DedupRow result type. Stores hashes and counters only — never content. DDL added to both Tracker::new and the test init_schema; cleanup_old prunes ledger rows idle beyond a 48h TTL so the table stays tiny. CRUD carries transitional allow(dead_code) until core::dedup consumes it in Phase 5. Covered by 4 in-memory tests (miss/insert/hit, cross-session isolation, per-session monotonic ordinal, bump increments emit_count). Phase 3 of session-level output dedup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nfig maybe_suppress hashes the raw (pre-filter) output with SHA-256 and, when dedup is enabled and a session id is present, suppresses a byte-identical re-emission to a one-line stub carrying step/lines/tokens and the recovery command (rtk proxy). Reuses the existing sha2 dep (collision-resistant = zero false-suppression risk) and estimate_tokens — no new dependencies. Guards each fall back to full output: disabled, no session, command failure (unless suppress_on_error), below min_tokens, or any DB error. Disabled/no-session fast paths do zero DB work, so the default-off feature adds no overhead. Core logic split into a state-free suppress_with for hermetic testing (9 unit tests via in-memory tracker). Adds [dedup] config (enabled=false, min_tokens=200, suppress_on_error= false) with a dedup() accessor. Module carries transitional allow(dead_code) until the print seams consume it in Phase 5. Phase 4 of session-level output dedup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
maybe_suppress is now called at the two print seams: read.rs (file and stdin paths, identity read:<path>) and runner::run_captured_filter's non-tee branch (identity = command label), covering git/gh/cargo and the other filters that route through the runner. The tee branch and the skip-filter-on-failure path are left full (failure-heavy, low-value). Non-suppressed output takes the exact original print path (byte-identical, no snapshot churn); only the Cow::Owned stub is printed verbatim. All transitional allow(dead_code) markers from Phases 1/3/4 are removed now that the chain is live. maybe_suppress checks session before config so manual invocations do zero I/O. Adds RTK_DEDUP=1 env override (force-enable without config) mirroring RTK_DB_PATH/RTK_HOOK_AUDIT — a user toggle and hermetic test hook. Verified end-to-end on the release binary: re-read within a session -> stub; different session -> full; no session -> full; --session flag path (hook injection) -> stub. Phase 5 of session-level output dedup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds Tracker::dedup_savings (SUM of output_tokens*(emit_count-1) and the repeat-emission count) and a "Dedup suppressed: X over N repeat-emissions" KPI line in rtk gain, rendered only when there are suppressions. Reported separately from filtering savings so the two never double-count; shown as a global figure since the ledger is session- not project-scoped. Also adds the deferred [dedup] config regression tests (missing section parses to safe defaults; full section round-trips) and a dedup_savings unit test (counts suppressed repeats only, not first emissions). Verified on the release binary: 3 reads in one session render "Dedup suppressed: 2.5K over 2 repeat-emissions". Phase 6 of session-level output dedup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds tests/dedup_integration_test.rs driving the real binary over the stdin read path with RTK_DEDUP=1 and isolated RTK_DB_PATH: suppresses an identical re-read within a session, never suppresses without a session id, and isolates suppression across sessions. Documents the dedup subsystem in ARCHITECTURE.md (session plumbing, the two-key safety model, guards, ledger, config/RTK_DEDUP, seams) and adds dedup.rs / session.rs to the search-strategy module map. Phase 7 (final) of session-level output dedup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stop Dedup is only safe while the earlier full emission is still in the agent's context; compaction can drop it. Two layers close that hole: - PostCompact ledger reset (primary): new `rtk hook compact` handler clears the session's ledger (Tracker::dedup_reset_session) from the PostCompact payload's session_id. `rtk init` now registers the PostCompact hook (no matcher -> fires for auto + manual), idempotently and alongside the existing PreToolUse hook; existing installs pick it up on re-running init. Uninstall strips it too. So dedup only suppresses within one un-compacted context epoch. - Recency window (backstop): [dedup] recency_window (default 100, 0=unlimited) refuses to suppress if the prior emission is more than N distinct emissions behind the latest — catches reductions that fire no compaction signal. Ordinals switched to MAX-based so they stay monotonic across resets. DedupConfig fields gained serde defaults, so enabling is now a one-liner ([dedup]\nenabled = true) instead of requiring the full section. Verified end-to-end on the release binary: read -> stub -> `rtk hook compact` -> read re-emits full. Covered by unit tests (recency window, compact_reset, PostCompact install/remove) + an integration test. Phase 8 of session-level output dedup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8c100cc to
093eeea
Compare
|
Correction to my earlier framing comment: I've now measured the actual repeat rate from my tracking DB instead of inferring it from filtering stats — the earlier claim tying this to the big-read savings line was overstated (those large reads turned out not to repeat byte-identically). The PR description now has a Measured impact section with the honest numbers: the repeat pattern is real (48.5% of post-filter output tokens are byte-identical re-emissions) but the yield is second-order on top of filtering, not a multiplier. Dogfooding the branch now; will report real |
When the settings.json patch is declined or stdin is non-interactive, the printed MANUAL STEP only ever showed the PreToolUse snippet — a user following it would silently drop the PostCompact (dedup ledger reset) hook. Generate the snippet from the actual missing hooks: PostCompact-only for existing installs, both for fresh ones. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RH7Q1Cq8kVfhoQ1WT2S1gP
Independent tooling-queue review PASS at exact SHA 4bcb080.
Summary
Adds session-level output dedup: when an agent re-reads the same file or re-runs the same command and the output is byte-identical to something already emitted within the same Claude Code session, RTK replaces it with a one-line stub instead of re-paying the tokens.
Filtering compresses a single command's output; this attacks a different sink — agents re-sending identical context every turn. Opt-in and off by default.
Measured impact (honest numbers)
I mined my own RTK tracking DB (943 commands over several weeks of real agentic use) to measure the repeat rate directly, instead of inferring it from filtering stats:
So the honest positioning: dedup is a second-order optimization on top of filtering — filtering removed ~894K tokens on the same history; dedup's realistic additional yield is in the tens of thousands. The repeat pattern it targets is real and pervasive (half of all output tokens), but the per-token payoff is bounded because filtering already crushed the large outputs. I'm dogfooding the branch now and will report actual
rtk gaindedup numbers here after some real use.How it works
hooks::hook_cmd::process_claude_payloadreadssession_idfrom the PreToolUse payload and splices--session <id>into the rewritten command.core::sessionresolves it (flag →RTK_SESSION_IDenv → none) into a process-globalOnceLock. No session (all manual invocations) ⇒ dedup no-ops and output is byte-for-byte unchanged.identityis only a cosmetic key for the stub message, so identity collisions are harmless. Reuses the existingsha2dep — no new dependencies.suppress_on_error), output belowmin_tokens, or any DB error. Suppression can only ever omit a repeat, never hide new/changed/failed output.session_outputstable in the tracking SQLite DB (keyed bysession_id+content_hash), storing hashes and counters only, never content; pruned after 48h idle.core::runner::run_captured_filter(non-tee branch — covers git/gh/cargo and most ecosystems) andcmds::system::read(file + stdin). Non-suppressed output takes the exact original print path (byte-identical, no snapshot churn).rtk gainshowsDedup suppressed: X over N repeat-emissions, separate from the filtering total so the two never double-count.Config / toggle
RTK_DEDUP=1force-enables without a config file (mirrorsRTK_DB_PATH/RTK_HOOK_AUDIT).Commits (each gated on
fmt + clippy + test --all)--sessionglobal flag +core::session--session <id>from the PreToolUse payloadsession_outputsledger + CRUDmaybe_suppressdecision fn +[dedup]configread+runnerprint seamsrtk gaindedup reportingTesting
maybe_suppressguard.tests/dedup_integration_test.rsdrives the real binary: suppresses identical re-read within a session, never suppresses without a session id, isolates across sessions.--sessionflag path → stub;rtk gainline renders).Known MVP limitations (safe degradations)
a && bcommands get--sessionon the firstrtksegment only; later segments run session-less and no-op (never incorrect).session_idin PreToolUse payloads, so a freshly-spawned subagent re-reading a file the parent already read receives a stub for content its context has never seen. The stub is self-describing (force full: rtk proxy <cmd>), so the cost is one recovery round-trip rather than silent wrongness — but it's a known soft spot, same class as the compaction caveat (mitigated by the PostCompact hook +recency_windowbackstop for the main-loop case).🤖 Generated with Claude Code