Spill is a capability seam that manages context overflow: when a tool produces more output than is healthy to place in the model's context, the spill machinery saves the full text to durable, session-scoped storage and hands the model a locator plus retrieval guidance instead. The large project file (or log) never enters the context window, but it is still addressable — the model can read/grep it later. This is one optional capability, not part of the agent-loop spine.
| Package | Role | ctx key |
|---|---|---|
packages/spill/spill | Service definition: SpillStore + request/result types | ctx.spillStore |
packages/spill/spill-local | Service provider: host-filesystem LocalSpillStore | registers as ctx.spillStore |
packages/spill/spill-policy | Consumer: the tools/post-execute policy that decides when to spill | registers no service |
The architecture is the seam + provider + policy split used across many dsh capabilities. The Service Definition owns storage only — no retention policy, no tool-result replacement, no retrieval/search API. The consumer policy owns the decision and the notice composition; the provider owns the mechanics.
The save request and result
The service has exactly one operation, saveText. The request carries the save-time storage namespace, the producing tool identity, a naming hint, and the full text:
interface SaveTextSpill {
owner: SpillOwner // { sessionId }
source: SpillSource // { toolName, callId, label }
suggestedName: string // e.g. "web_fetch.txt" — a hint, never a path
content: string // the FULL text to persist
}owner.sessionId is the save-time storage namespace — a backend groups storage under the producing session, but the returned locator is the model-facing handle. Forked sessions inherit locators already present in the seeded log; those artifacts are not copied or re-owned, and spills produced after the fork use the child session id. source is purely descriptive (used for a readable filename and inspection, never access control).
interface SpillRef {
locator: SpillLocator // Branded<'SpillLocator'> — opaque model-facing handle
bytes: number // exact byte count of what was persisted
retrievalHint: string // backend-supplied guidance (e.g. "Use read or grep")
}SpillLocator is intentionally opaque: the local backend renders a filesystem path, but a remote or database backend can render a URI, key, or command token. Consumers render it with retrievalHint, not by parsing it.
The service: SpillStore
SpillStore (ctx.spillStore, packages/spill/spill/src/types.ts + index.ts) is a one-method abstract service:
abstract saveText(input: SaveTextSpill): Promise<SpillRef>It persists the FULL content verbatim and rejects on a real storage failure (permissions, ENOSPC, backend unavailable) — it never truncates and never pretends a failure is success. The caller decides how to degrade (the policy treats a rejection as best-effort). Storage is scoped by input.owner.sessionId; the backend must choose a private (not world-readable) location and a collision-free name derived from, never equal to, the caller's suggestedName.
The local provider: LocalSpillStore
dsh-spill-local writes to the host filesystem under a privacy-preserving layout:
<root>/session-<sha256-prefix(sessionId)>/<random>-<safeName>root: the configuredroot, else a lazily-created private (0700) per-process directory under the OS temp dir (the safe default for a local deployment).session-<hash>: a session subdirectory named from the first 12 hex chars ofsha256(sessionId)(48 bits), binding stored files to their owning session.<random>-<safeName>: an unpredictable prefix plus the sanitized single-safe-segment name derived fromsuggestedName.- The write is exclusive and owner-only —
open(path, 'wx', 0o600)— so a planted symlink cannot redirect it. A spilled tool result must not be readable by other local users.
Its locator is the local absolute path and its retrievalHint is 'Use read with offset/limit, or grep this path to search within it.' The retrieval contract therefore is plain read/grep on the co-located local path.
The policy: when and how output spills
dsh-spill-policy is not a service — it is a tools/post-execute result transformer. Registered as a prepended listener, it decides when to spill and composes the replacement notice. Its one config, maxInlineBytes, is the cap:
- Omitted ⇒ the plugin registers nothing (a true no-op).
- Set ⇒ any plain-text final result larger than this is saved fully to
ctx.spillStoreand replaced with a bounded head/tail preview plus the spill reference.
The preview and notice come from @deepseek-ai/dsh-output-retention's TextRetainer (kind: 'headTail', budget split across the two ends) with an omission description:
(Omitted N bytes. Full formatted result stored at: <locator>. <retrievalHint>)The policy is deliberately narrow:
- Plain-text results only — any non-text block leaves the result untouched (the policy only knows the final formatted text).
- Best-effort by design — if there is no session owner, no
ctx.spillStorebackend, or a save failure, it logs and returns the original inline result. A spill failure must never turn a successful tool call into anisErroror hide the inline result. readis skipped by the model-facing arm to avoid aread → spill → read againloop (the durable-log arm does boundreadsub-calls, since a log copy is not model context).- A second arm applies the same cap to the durable log: the
tools/code-dispatch-logwaterfall bounds thetool/code-dispatchevent's copy of an oversizedrun_codesub-call result. The program's value is untouched; UIs and replay read the full text through the spill artifact.
This design means read/grep results that exceed the cap from large files, web fetches, or search output are the canonical spill candidates — exactly the case flagged for the retained sampleOverCapGlobResults output in the discovery tools.
Lifecycle sketch (ASCII)
tool result (oversized) → tools/post-execute (spill-policy)
│
├─ size ≤ maxInlineBytes? → keep inline (no-op)
└─ size > maxInlineBytes? →
SpillStore.saveText(full text → <root>/session-<hash>/<rand>-<safe>)
replace model result with:
[head|…|tail] (… Omitted N bytes. Full formatted result stored at: <locator>. <hint>)Packages
| Package |
|---|
@deepseek-ai/dsh-spill |
@deepseek-ai/dsh-spill-local |
@deepseek-ai/dsh-spill-policy |
Further reading
- Context sources & compaction — the other mechanism that keeps context bounded
- Tool registry & execution pipeline — the
tools/post-executeextension point - Filesystem capabilities —
read/grepas the retrieval path for spilled locators docs/subsystems/spill.md— the official Spill Storage referencepackages/spill/spill/src/types.ts—SaveTextSpill,SpillRef,SpillLocatortypespackages/spill/spill-policy/src/index.ts— the decision logic and notice framing