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.
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.
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.
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.
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.
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: 8192Without the LLMLingua-2 sidecar the deterministic semantic strategy runs on its own, which is the supported default and needs no Python.
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/pipOnly compressible regions are ever sent to the model. Protected regions are
reconstructed byte-for-byte.
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.
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.
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.
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.
-
referencelines 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
criticalspans alone exceed the budget the content is left unpruned andcompaction-basicmay 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.tsencode an ordering claim, not a calibrated model. There is no relevance-to-question signal yet — the seam passespruneContentno 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
llmlinguainstall 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 withOSError 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=Rsettles it on real sessions. The model path replaces the whole result and must still fitthresholdChars, while protected regions are reconstructed byte-for-byte. Measured on 12 real over-threshold results (98,092 characters of deterministic output):rateadopted marginal gain vs. deterministic 0.50 / 12 0.0% 0.32 / 12 4.0% 0.155 / 12 9.5% At the documented
rate: 0.5the 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 aggressiverateclears the bar, and at0.15the 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 coldtorchimport, and ~0.5 s per region. The deterministic strategy is the product; the sidecar is an experiment. Enable it only with arateyou 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.mjsis the guard, and it fails if any real region does.
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.