DeepSeek Harness · Context Engineering

Spill: When Tool Output Is Too Large

Over-limit output is archived to disk; the model keeps a retrieval credential. Core source:packages/spill/spill-policy/src/index.ts

Course goalAfter this lesson you can explain three things: when a 2MB grep result arrives, why DSH neither stuffs it into context nor chops it — full text to disk, head/tail preview plus a retrieval credential in context; what each of the five post-execute steps decides and why the read tool is skipped; and why a storage write failure still counts as success.
Interactive demo · large-output handling pipeline
Model context (what the model can see)
Archive cabinet spillStore (session-private files)
POST-EXECUTE five-step decision (spill-policy/src/index.ts)
1 · next() delegateLet downstream settle the result first · L194
2 · Plain-text checkAny non-text block → leave the whole result alone · L200-201
3 · Byte thresholdAct only when UTF-8 size exceeds maxInlineBytes · L202-203
4 · saveText to diskWrite full text as-is into the session archive · L155
5 · Replace with preview + credentialHead/tail preview plus retrieval hint into context · L173-175
Contrast · hard truncation
Chop to the cap, keep only the startThe chopped part no longer exists
Stick a truncated markerTell the model it was truncated — nothing more
Hit Play to run Scenario A, or scroll here to auto-play.
Teaching simulation: cards, byte counts, and paths are course abstractions; decision logic maps to packages/spill/spill-policy/src/index.ts lines 190–209. Demo maxInlineBytes is 50 KB, matching the Agent Note sample deploy.
Three candidate answers to this question

A grep hits tens of thousands of lines, or web_fetch pulls back a whole document — 2MB. Where does that result go next? Only three options.

Option one: stuff it all into context. The next model request is packed full — wallet and context window both take the hit. Option two: chop the overflow. You save space, but if the model later needs exactly the chopped error line, the task is stuck. Option three is DSH: archive the full text to disk, leave only head/tail previews in context plus a hint naming the path and saying read or grep can recover it. What left context can still be found — that's Spill (overflow-to-disk).

Truncation drops information; Spill can fetch it back.

The plugin doing this is dsh-spill-policy. It hangs on the tool pipeline's tools/post-execute event and only acts after a tool result is finalized. The whole decision is five steps — Agent Note 2026-07-08 spells them out: delegate, plain-text check, byte threshold, saveText, replace. The checklist in the demo is those five steps copied as-is.

Core visual · Teaching diagram
Tool result finalized tools/post-execute · after next() Plain text AND > maxInlineBytes? Skip the read tool outright — prevent a loop No · pass through as-is Model context What the model can see Yes saveText() full text to disk ctx.spillStore · on failure keep inline Archive file (0600 exclusive write) session-<hash>/<random>-grep.txt Head/tail preview + retrieval credential locator + retrievalHint Model · read / grep Recover full text anytime via the credential
Teaching diagram: nodes and edges explain source relationships; content is course-adapted.
Three easy mix-ups
Plain text only

If any non-text block sneaks in (a screenshot, say), flattenPlainText returns undefined and the whole result stays as-is. The policy only knows final formatted text, not tool internals — so it would rather not touch it. Source: index.ts lines 80–87.

read is skipped

The model-facing arm explicitly skips the read tool, to stop a loop where read output is spilled to a file, the model reads again, spills again. The log arm does not skip — log copies never enter model context, so the loop cannot form. Source: lines 195–197 and comments at 219–222.

The credential is not a path

locator is an opaque handle: the local backend may give a file path; a remote backend may give a URI or key. Consumers do not parse it — they render the retrieval wording from the backend's retrievalHint, never assuming read is always the right recovery method. Source: docs/subsystems/spill.zh.md line 70.

Gatekeeping logic of the five-step decision

The policy entry is a chain of pass-through checks — four reasons not to touch, checked in order: a downstream listener did not accept the result; another plugin already replaced the value; this is a nested sub-call or the read tool; content mixed in a non-text block. Any hit → pass through as-is. If none hit, measure bytes; under maxInlineBytes also passes. Only when all clear does spill run.

An easy-to-miss ordering detail: step one is await next() — delegate first. Let downstream listeners (a content-replacing hook, say) fully settle the result; only then does spill touch the final draft. So even if another plugin swapped the content, the swapped content is still under spill's watch.

Source: packages/spill/spill-policy/src/index.ts lines 194–209, verified on 2026-08-13. Why skip read is in the source comments: avoid a loop where read output is spilled to a file, the model reads again, spills again.

Key evidence · Storage failure does not rejudge

That edge case from the outline: if spill storage write fails, does this tool call count as success or failure? The answer is in the catch branch — success, and not one character is hidden.

packages/spill/spill-policy/src/index.tslines 153–161 excerpt
    let ref: SpillRef
    try {
      ref = await spillStore.saveText(save)
    } catch (error: unknown) {
      // Best-effort: a storage failure (permissions, ENOSPC, backend down) must
      // never fail the call or hide the content — keep the original inline.
      ctx.logger.warn(`spill-policy: saveText failed for ${toolName}: ${String(error)}; keeping the inline content`)
      return undefined
    }
Source snapshot note:Based on the local deepseek-harness-master repo; verified against packages/spill/spill-policy/src/index.ts, verified on 2026-08-13. Code blocks keep the original source text.

Disk full, bad permissions, backend not mounted — all you get is a warn log, then the original result stays inline in context. The design note's exact line: “a spill failure must never turn a successful tool call into an isError result, nor hide the inline content” (Agent Note 2026-07-08 line 91). The logic is plain: spill is a cost-saving optimization; if it fails, context gets a bit fatter at worst — never turn a successful call into failure, and never lose information.

The opposite pitfall is plugged too: config validation runs at plugin load, not per call. A negative or fractional maxInlineBytes fails deploy startup outright (lines 114–119) — bad config should crash the deploy, not make some tool call take the blame.

What the credential looks like

After replacement, model-visible text is three parts: kept head preview, omission note plus credential, kept tail preview. The credential line is built by spillNotice (lines 104–108); Agent Note's example is “(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)”. Wording stays deliberately generic — the policy only knows final text, not tool-internal resources.

One detail shows how strict this code is: the credential's own byte count is subtracted from the maxInlineBytes budget before sizing the preview (lines 171–172). Otherwise the preview could fill the budget, the credential sticks on, and the replacement ends up larger than the cap. If the credential line alone exceeds the whole cap, the policy simply abandons spill and keeps inline — never violate its own stated limit (lines 183–185).

The archive file itself is careful. The local backend writes to <root>/session-<hash>/<random>-<safeName>, private root (0700), write via open(path, 'wx', 0o600) exclusive and owner-readable — a pre-planted symlink cannot redirect the write (docs/subsystems/spill.zh.md line 85). A sibling design is the attachment system: reference in the log, bytes in an external store — same idea, body keeps only a light reference (docs/subsystems/attachment.zh.md).

Side-by-side · How three systems handle large output

Claude Code · cap + spill to disk

The official blog says “For Claude Code, we restrict tool responses to 25,000 tokens by default”; source maps to Tool.ts maxResultSizeChars (study/chapters/02-tool-system.md lines 664–666). Line 670 notes that over-limit tool results spilled to disk come with a path note — same direction as DSH. The blog adds a principle: when truncating, tell the Agent why and how to get the full content.

Grok Build · bash-only spill

Default tool-output cap is 20,000 bytes (DEFAULT_TOOL_OUTPUT_CHARS, crates/codegen/xai-grok-tools/src/lib.rs line 11); over-limit truncates and returns a truncated flag. bash is the exception: full output is written first to a terminal log in the session dir (bash/mod.rs lines 379–381); the chopped part can be recovered from the file. That spill is bash-only — for other tools, truncated means gone.

The comparison focus is generality. All three admit large output cannot all feed the model; the difference is whether the dropped part can be recovered, and across how many tools. Grok spills only for bash; Claude Code and DSH made it a general mechanism. DSH's cut is the finest: preview lives in the output-retention library, storage in the spillStore seam (a one-method abstract service), and the policy plugin only decides when to spill and how to build the credential. Three packages each own a slice — swap a remote storage backend without touching a line of policy. Agent Note's alternatives section even names the reference: make it the general default, aiming at “Claude Code–like general tool-result persistence” (line 187).

Classroom Exercise
01

Hand-trace three outputs' fates

Deploy config maxInlineBytes: 50000. Three tool results arrive in order: a 60,000-byte web_fetch plain text; a 200,000-byte browser-screenshot result mixed with an image block; an 80,000-byte plain text that hits a full disk on spill. Using the gate logic at lines 194–203 and the catch branch at 153–161, write each result's final shape in model context, and how many files the archive cabinet gained.

Takeaway:Spill frees large output from the stuff-it-or-drop-it binary: full text to disk, preview plus credential in context; the model recovers anytime with existing read/grep. The policy only handles finalized plain text, skips read to prevent loops, keeps inline on storage failure without flipping isError. Truncation drops information; Spill can fetch it back — two views of context.