Skip to content

Repository files navigation

dsh-context-compressor

Agent-safe tool-result compression for DeepSeek Harness.

The shipped dsh-compaction-tool-result-pruner is deliberately syntactic: it keeps the leading 4096 code points, inserts an omission marker, and keeps the trailing 1024 — "retains the beginning and end without interpreting which middle lines are semantically important", as its own README puts it. That is fine for prose and destructive for the things a coding agent actually needs: the error in the middle of a build log, the JSON member that carries an exit code, the path the next edit will target.

This package registers the same service name and answers the same contract, but decides what to keep by content rather than by position.

What it does

segment  →  classify      →  select           →  gate
lines       critical          mandatory first     size ≤ threshold
            reference         then by score       critical spans present
            structure         (LLMLingua-2        structural braces balanced
            compressible       for prose, when warm)

Four protection levels, settled before any scoring runs:

Level Treatment Examples
critical Never dropped; if it will not fit, nothing is pruned at all JSON members and delimiters, code fences, diff headers, shell commands, error lines
reference High priority, droppable under budget pressure File paths, source locations
structure Preferred over prose, droppable under budget pressure JSON region interiors
compressible Scored, and optionally token-compressed by LLMLingua-2 Prose, unremarkable output

The safety property is graded, deliberately. Losing an error line sends the agent in the wrong direction; losing a path costs it one extra tool call. Those are not the same loss, so they do not share a policy.

The grading also exists because refusing to prune is not a safe fallback. dsh-compaction-basic re-measures after a null and, still over threshold, hands the whole range to LLM summarization — which rewrites protected content too. A refusal therefore escalates to something strictly lossier than dropping a few reference lines. Only content whose critical spans alone exceed the budget declines.

⚠️ The claim above does not survive measurement — see ADR-0001.

"100% critical retention" was graded by regular expressions written next to the selector, and the positional comparison ran at a smaller budget — 5159 characters against this package's 8192 — so the package was credited for keeping more rather than for choosing better.

Measured instead against the agent's own behaviour, meaning which strings from a tool result the agent went on to reuse, at equal budget:

Budget Positional This package
8192 81.2% 76.3%
4096 79.4% 72.6%
2048 68.8% 69.2%

The heuristic selection route is closed. Reproduce with node benchmark/bench-utility.mjs --control.

/compact [low|medium|high|max]

The shipped dsh-command-compact is deliberately argument-free — any input returns a usage error — and its handler calls compactNow, which takes no policy, so how much history is retained is service configuration fixed for the whole session. It cannot be given a depth argument.

This bundle disables that row and registers the same command name with one:

/compact            default (medium)
/compact low        fold the oldest quarter of the compactable history
/compact medium     half
/compact high       three quarters
/compact max        everything the harness will accept

The level selects how far back the compaction reaches, via compactRegion(start, end) — the only per-invocation knob the seam offers. Levels rather than a token count because an absolute budget means nothing without knowing the model's window and current usage: the same 4096 is "most of it" on a 32K model and "almost nothing" on a 128K one.

Cut edges come from the harness's own toolPairingBalancedBefore / After predicates, so a cut that no valid edge satisfies is reported rather than forced. Two things this path does not do that compactNow does: it cannot mark the compaction as manually sourced (it takes no sourceCommandId), and it does not enforce the busy/idle bracket — compactRegion throws instead.

The command mounts only when ctx.compaction is present. A composition without a compaction engine defers this plugin silently: no command appears and nothing errors.

Service API

Registers ctx.toolResultPruner. dsh-compaction-basic finds it through its optional ctx.get('toolResultPruner') lookup, so the consumer needs no change — only the composition row differs.

pruneSession(session) rewrites over-budget tool/result surface nodes: each replacement cites the shadowed node via surfaceOp and sourceEventSeqs, is preceded by the compaction/prune shadow-price event, and preserves every field except content. The original event stays in the append-only log.

measureContent(blocks), pruneContent(blocks), and config match the shipped contract. stats is additive.

Config

Unrecognized keys fail at plugin construction.

Key Required Meaning
thresholdChars no (default 8192) Prune when combined text exceeds this many Unicode code points.
headChars no (default 4096) Leading code points retained by the envelope strategy and its fallback.
tailChars no (default 1024) Trailing code points retained by the envelope strategy.
strategy no (default semantic) semantic or envelope.

headChars + marker + tailChars must fit within thresholdChars even under semantic, because they parameterize the fallback path.

Usage

Replace the shipped row. Either apply cordis.patch.yml as a bundle, or make the same two edits by hand:

# Drop the shipped syntactic pruner — both register `toolResultPruner`.
# - id: tool-result-pruner
#   disabled: true

- id: context-compressor-pruner
  name: 'dsh-context-compressor'
  config:
    thresholdChars: 8192

Without the LLMLingua-2 sidecar the deterministic semantic strategy runs on its own, which is the supported default and needs no Python.

Attaching the model (optional)

The seam's entry point is synchronous, so no model call can happen inside pruneContent. Model output is therefore precomputed into a bounded cache and read synchronously at prune time; a cold region falls through to the deterministic strategy.

import { LlmlinguaSidecar, ModelCompressor } from 'dsh-context-compressor'

const sidecar = new LlmlinguaSidecar(ctx.subprocess, {
  argv: [pythonPath, sidecarScript, '--device', 'cpu'],
  cwd: pluginDir,
  requestTimeoutMs: 120_000,
  graceMs: 5_000,
})
prune.useModel(new ModelCompressor({ sidecar, params: { rate: 0.5 } }))

minRegionChars (default 200) is the knob that decides whether the sidecar pays for itself. Real tool results fragment their prose: across 138 real over-threshold results, one 29 189-character result held 81 compressible regions averaging 141 characters. At ~0.88 s per round trip, compressing those individually would spend 73 s to remove a few hundred characters. Regions below the floor are handed back unchanged — not treated as a miss — so a fragment of prose never forces the whole result down the deterministic path.

useModel also subscribes to session/event, so content is compressed as it lands and the next prune finds a warm cache. Detaching (useModel(undefined)) releases the subscription.

The sidecar is Python, so it must reach a llmlingua install:

python -m venv --system-site-packages .venv
.venv/Scripts/pip install llmlingua      # POSIX: .venv/bin/pip

Only compressible regions are ever sent to the model. Protected regions are reconstructed byte-for-byte.

Diagnostics

prune.stats
// {
//   prunes, charsRemoved, declined,
//   declineReasons: { 'no-budget', 'critical-over-budget', 'nothing-dropped',
//                     'size-contract', 'quality-gate' },
//   model: { hits, misses, warmed, failed, inferenceSeconds },
// }

declined counts over-budget results left intact because pruning would have damaged protected content. declineReasons says why, because the reasons call for opposite responses:

Reason What it means What to do
critical-over-budget Mandatory content alone exceeds the budget Raise thresholdChars — the configuration does not fit this workload
quality-gate A rewrite was rejected after selection Investigate — this is a defect signal, not a tuning signal
no-budget The threshold cannot cover the omission marker Raise thresholdChars
nothing-dropped Every segment fits or is mandatory None
size-contract The rebuild missed the size bound Investigate

The distinction matters: a bare declined count cannot separate "your threshold is too small" from "the selector is broken", and the last investigation needed an external script to reconstruct the decision path.

Model Experience

Pruned tool result

The model sees the retained lines in original order with one [... tool result middle pruned ...] marker where content was dropped, or token-compressed prose for the compressible regions when the model path is warm. Errors, paths, JSON members, and commands are present verbatim.

Token effect

Bounded by thresholdChars as before, but the retained budget is spent on information rather than on position. Pruning consumes no model call on the request path; LLMLingua-2 inference happens ahead of time, and only for content that exceeds the threshold.

KV cache effect

Unchanged from the shipped pruner: replacing an earlier result invalidates reuse from the first changed token. Because this package drops different tokens than the positional slice would, the two are not interchangeable across a running session — pick one per deployment.

Known Limitations

  • reference lines are not guaranteed. Paths and source locations can be dropped when the budget is tight. This is the deliberate cost of the tiering; the earlier all-or-nothing rule preserved them only by refusing to compress at all, which escalated to summarization.

  • The compression ratio is lower than the shipped pruner's. 1.9–3.9x against 3.1–7.0x on real sessions. The shipped pruner reaches its ratio by discarding a fixed 4096–1024 character window regardless of content; the difference is exactly what it throws away. At the default 8192 threshold the ratio is 1.94x.

  • A decline still escalates. When critical spans alone exceed the budget the content is left unpruned and compaction-basic may fall through to LLM summarization. Measured at 1.4–3% of over-threshold results, down from 55–64% before the tiering, but not zero.

  • Line granularity. A single enormous line (a minified bundle, a 200 KB JSON one-liner) is one segment and cannot be partially retained by scoring; the envelope still handles it.

  • Scoring is heuristic. Weights in src/score.ts encode an ordering claim, not a calibrated model. There is no relevance-to-question signal yet — the seam passes pruneContent no question to condition on.

  • Region-level cache granularity. Model warming is all-or-nothing per prune: one cold region sends the whole content down the deterministic path, so output does not depend on cache timing.

  • The sidecar needs a working llmlingua install and ~900 MB resident. The weights are memory-mapped (model_config={'low_cpu_mem_usage': True}), which is what keeps this workable — without it the same load fails outright on Windows with OSError 1455. Most of the footprint is not the model: ~430 MB is torch/transformers/llmlingua import overhead before a single weight is touched, and 367 MB of the 709 MB weight file is a 119k-token multilingual embedding table.

  • The sidecar's marginal value is measured, and it is small. This was open for a while; node benchmark/compare-model.mjs --threshold=8192 --rate=R settles it on real sessions. The model path replaces the whole result and must still fit thresholdChars, while protected regions are reconstructed byte-for-byte. Measured on 12 real over-threshold results (98,092 characters of deterministic output):

    rate adopted marginal gain vs. deterministic
    0.5 0 / 12 0.0%
    0.3 2 / 12 4.0%
    0.15 5 / 12 9.5%

    At the documented rate: 0.5 the model's output is discarded every time: on the most favourable result in the set, 649 protected characters and 11,852 compressible ones still total 8,977 after compression, 785 over an 8,192 threshold. Only a much more aggressive rate clears the bar, and at 0.15 the model keeps 15% of tokens — a summarization-grade rewrite of prose, which is the lossy behaviour this plugin otherwise exists to avoid. The cost is ~900 MB resident, a ~14 s cold torch import, and ~0.5 s per region. The deterministic strategy is the product; the sidecar is an experiment. Enable it only with a rate you have measured on your own corpus.

  • The sidecar speaks UTF-8 explicitly, and must continue to. On Windows a pipe inherits the ANSI code page (cp936 here), so the host wrote UTF-8 into a stream the sidecar decoded as cp936 and answered in cp936 for a host reading UTF-8. ASCII-only test data cannot see this — the encodings agree on ASCII — and the symptoms (tokenizer errors, 30 s hangs on 200-character inputs) point at the model rather than the transport. configure_streams() is what keeps that from coming back; benchmark/probe-real-regions.mjs is the guard, and it fails if any real region does.

License

MIT. The seam protocol, the shadow-price contract, and the ported test suite derive from the MIT-licensed @deepseek-ai/dsh-compaction-tool-result-pruner.

About

An Agent-safe context compressor for DeepSeek Harness. It semantically preserves critical tool outputs such as errors, file paths, JSON payloads and command results, instead of mechanically truncating the context.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages