Skip to content

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.

PackageRolectx key
packages/spill/spillService definition: SpillStore + request/result typesctx.spillStore
packages/spill/spill-localService provider: host-filesystem LocalSpillStoreregisters as ctx.spillStore
packages/spill/spill-policyConsumer: the tools/post-execute policy that decides when to spillregisters 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:

ts
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).

ts
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:

ts
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 configured root, 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 of sha256(sessionId) (48 bits), binding stored files to their owning session.
  • <random>-<safeName>: an unpredictable prefix plus the sanitized single-safe-segment name derived from suggestedName.
  • The write is exclusive and owner-onlyopen(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.spillStore and 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:

text
(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.spillStore backend, or a save failure, it logs and returns the original inline result. A spill failure must never turn a successful tool call into an isError or hide the inline result.
  • read is skipped by the model-facing arm to avoid a read → spill → read again loop (the durable-log arm does bound read sub-calls, since a log copy is not model context).
  • A second arm applies the same cap to the durable log: the tools/code-dispatch-log waterfall bounds the tool/code-dispatch event's copy of an oversized run_code sub-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-execute extension point
  • Filesystem capabilitiesread/grep as the retrieval path for spilled locators
  • docs/subsystems/spill.md — the official Spill Storage reference
  • packages/spill/spill/src/types.tsSaveTextSpill, SpillRef, SpillLocator types
  • packages/spill/spill-policy/src/index.ts — the decision logic and notice framing