Certified Agent Harness Engineer
Course sections

M4.5

Prune tool results

806 words · about 5 min

The concept

Concept

Pruning is the highest-yield, lowest-risk reclamation, and it is the first rung for a reason. In a coding agent, tool results typically dominate history by volume: file contents, search output, build logs, test output. Most of that volume is mechanical, and most of the middle of any given result is the least useful part. The beginning carries structure — headers, the first matches, the top of the file. The end carries conclusions — the final error, the summary line, the last matches. The middle is repetition.

The risk profile is what makes it the first rung: pruning does not change the shape of the conversation, does not require a model call, and does not remove any reasoning. If the model needs the pruned middle, it can re-run the tool.

The mechanism

Mechanism

packages/compaction/compaction-tool-result-pruner/src/config.ts defines the entire policy in three numbers:

export const PRUNE_MARKER = '\n\n[... tool result middle pruned ...]\n\n'
export const DEFAULTS: ResolvedConfig = deepFreeze({
  thresholdChars: 8192,
  headChars: 4096,
  tailChars: 1024,
})

Results under 8,192 characters are untouched. Larger ones keep their first 4,096 and last 1,024 characters with an explicit marker between. Config validation requires headChars + marker + tailChars <= thresholdChars — a policy that could produce a "pruned" result no smaller than the original is rejected at load.

Two implementation details matter. First, slicing is by code point, not UTF-16 code unit:

export function codePointLength(text: string): number { return Array.from(text).length }

Slicing by code unit can split a surrogate pair and produce invalid UTF-8 — a lone surrogate that breaks JSON serialisation and reaches the provider as a malformed request. Second, non-text blocks within a result are retained at their original relative positions: pruning shrinks text, it does not reorder or discard structure.

The replacement follows exactly the same shadowing protocol as compaction, from packages/compaction/compaction-tool-result-pruner/src/index.ts:

session.append('compaction/prune', {
  shadowedRange: { start: seq, end: seq },
  shadowedSeqs: [seq],
  shadowedTokenCount: this.ctx.tokenMeter.estimateMessage(event.data.message),
})
const replacement = session.append('tool/result', { ...event.data, message }, {
  surfaceOp: { op: 'replace', start: seq, end: seq },
  sourceEventSeqs: [seq],
})

The compaction/prune event records the shadow price before the replacement lands, so the accounting projection can subtract the right amount. The original result stays in the log; only the surface changes. Same invariant, same auditability.

Worked example

A grep across a large codebase returns 200,000 characters. Under the dsh heuristic that prices at roughly 200000 / 4 + 8 = 50,008 tokens — 39% of a 128k window, from one tool call.

After pruning: 4096 + 39 + 1024 = 5,159 characters, pricing at about 5159 / 4 + 8 = 1,298 tokens.

Reclaimed: about 48,710 tokens from a single result. No model call, no lost reasoning, and the model still sees the first several dozen matches and the last few. Compare that to compaction, which might reclaim a similar amount at the cost of a summarisation round trip and the loss of every intermediate decision. This is why the ladder is ordered the way it is.

Tradeoffs

dsh's own README is blunt about the limitations, and they are real:

"Character budgets are not token budgets." The policy is expressed in characters but the pressure is in tokens, and the ratio varies with content. 4,096 characters of English is roughly 1,024 tokens; 4,096 characters of base64 is considerably more. The pruner is deliberately not token-aware, trading precision for a cheap synchronous operation with no dependency on the meter's accuracy.

"Pruning is syntactic." It retains the beginning and end without interpreting which lines matter. For a stack trace, where the important frames are often in the middle, head-and-tail is exactly the wrong heuristic. A semantically-aware pruner would be better and would require understanding every tool's output format — which is why dsh pushes format-aware truncation down to the tools themselves (Module 3, M3.5) and keeps this layer dumb and universal.

"Grapheme clusters can split." Code-point slicing avoids invalid UTF-8 but can still split a family emoji or a combining sequence. This is a cosmetic defect, not a correctness one, and it is documented rather than hidden.

Finally, pruning is irreversible within the surface: once pruned, the model cannot see the middle again in this conversation, even if it turns out to matter. The original is in the log, but the log is not what the model reads.

How others differ

Most harnesses truncate at the tool boundary — the read tool caps its own output, the bash tool caps its own. That is simpler and format-aware, but it cannot reclaim anything retrospectively: a result that was reasonable when produced stays in history at full size forever. dsh's separate pruner can revisit results that have already been in history for twenty turns, which is precisely the situation that causes pressure. The two approaches are complementary, and a serious harness wants both.


Check yourself

2 questions on M4.5

Start drill