diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 51a14b9162..0e18f44c01 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -78,7 +78,7 @@ flowchart LR
pkg_web_search_deepseek["web-search-deepseek"]
pkg_web_fetch_local["web-fetch-local"]
pkg_spill["spill"]
- svc_spillFiles["ctx.spillFiles
Spill storage seam"]
+ svc_spillStore["ctx.spillStore
Spill storage seam"]
pkg_spill_local["spill-local"]
pkg_spill_policy["spill-policy"]
pkg_workflow["workflow"]
@@ -111,8 +111,8 @@ flowchart LR
pkg_session_persistence_sqlite --> svc_sessionPersistence
pkg_skill --> svc_skills
pkg_skill_local --> svc_skills
- pkg_spill --> svc_spillFiles
- pkg_spill_local --> svc_spillFiles
+ pkg_spill --> svc_spillStore
+ pkg_spill_local --> svc_spillStore
pkg_stdio_agent --> svc_userInteraction
pkg_subagent --> svc_subagents
pkg_subagent_acp --> svc_subagents
@@ -154,7 +154,7 @@ flowchart LR
svc_sessions --> pkg_session_persistence
svc_sessions --> pkg_subagent_inprocess
svc_skills --> pkg_tool_skill
- svc_spillFiles --> pkg_spill_policy
+ svc_spillStore --> pkg_spill_policy
svc_subagents --> pkg_tool_subagent
svc_systemPrompt --> pkg_agent_loop
svc_systemPrompt --> pkg_tool_fs
@@ -197,7 +197,7 @@ flowchart LR
| `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred. |
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-mock`](../packages/support/subagent-mock) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. |
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
-| `ctx.spillFiles` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text to a session-scoped path; spill-policy is the tools/post-execute consumer that decides when to spill. |
+| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow) | - | One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents. |
Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard.
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 808779dd8f..f57dcb147f 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1211,7 +1211,7 @@ Abstract service classes — a deployment loads a concrete implementation packag
- `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts))
- `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts))
- `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts))
-- `@deepseek-ai/dsh-spill` — abstract `SpillFiles` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts))
+- `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts))
- `@deepseek-ai/dsh-workflow` — abstract `WorkflowService` ([`packages/workflow/workflow/src/index.ts`](../packages/workflow/workflow/src/index.ts))
## Library packages (no plugin entry)
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index d2ff829538..5328a8c86a 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -232,13 +232,13 @@ async get(name: string, options: SkillLookupOptions = {}): Promise
```
-Source: [`packages/spill/spill/src/index.ts:46`](../../packages/spill/spill/src/index.ts)
+Source: [`packages/spill/spill/src/index.ts:45`](../../packages/spill/spill/src/index.ts)
## `ctx.subagents` — `SubagentService`
diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md
index 151ba22deb..93038cc33a 100644
--- a/docs/core-data-structures/core.md
+++ b/docs/core-data-structures/core.md
@@ -29,7 +29,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface |
| [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split |
| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider/capability status, `WebError` |
-| [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillPath` |
+| [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` |
| [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality |
> Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts.
diff --git a/docs/core-data-structures/spill.md b/docs/core-data-structures/spill.md
index 7586d70633..afc825f09d 100644
--- a/docs/core-data-structures/spill.md
+++ b/docs/core-data-structures/spill.md
@@ -1,12 +1,12 @@
# Spill Storage
-The spill storage seam — a [capability seam](../rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md) that persists a tool's oversized text to a session-scoped path the model can later `read`, split across packages: interface ([dsh-spill](../../packages/spill/spill), `ctx.spillFiles`), implementation ([dsh-spill-local](../../packages/spill/spill-local), private session-scoped files on the host filesystem), and consumer ([dsh-spill-policy](../../packages/spill/spill-policy), the `tools/post-execute` policy). Spill is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Preview mechanics stay in [dsh-retention](../../packages/util/retention); this seam only saves the final text the policy hands it.
+The spill storage seam — a [capability seam](../rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md) that persists a tool's oversized text and returns a model-facing locator plus retrieval guidance, split across packages: interface ([dsh-spill](../../packages/spill/spill), `ctx.spillStore`), implementation ([dsh-spill-local](../../packages/spill/spill-local), private session-scoped files on the host filesystem), and consumer ([dsh-spill-policy](../../packages/spill/spill-policy), the `tools/post-execute` policy). Spill is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Preview mechanics stay in [dsh-retention](../../packages/util/retention); this seam only saves the final text the policy hands it.
Source: [`packages/spill/spill/src/types.ts`](../../packages/spill/spill/src/types.ts)
## The save request
-`saveText` is the whole seam: persist `content` verbatim, return a readable path plus the exact byte count. The request carries WHO the artifact belongs to (`owner`), WHERE it came from (`source`, descriptive provenance for the filename and future cleanup — not access control), and a `suggestedName` the backend sanitizes to one safe path segment before use (it is a hint, never a path).
+`saveText` is the whole seam: persist `content` verbatim, return an opaque locator, a backend-supplied retrieval hint, and the exact byte count. The request carries WHO the artifact belongs to (`owner`), WHERE it came from (`source`, descriptive provenance for naming and future cleanup — not access control), and a `suggestedName` the backend may use as a naming hint (it is not a path).
```ts type-equiv
interface SaveTextSpill {
@@ -37,19 +37,20 @@ interface SpillSource {
```ts type-equiv
interface SpillRef {
- path: SpillPath
+ locator: SpillLocator
bytes: number
+ retrievalHint: string
}
```
-`SpillPath` is a [branded](core.md#branded-ids) local filesystem path returned by the backend and intended for the model's `read` tool. The brand records that the path came from the spill seam (a runtime artifact, not a workspace file the model authored); it is still rendered to the model as an ordinary path string in v1. A future remote or virtual backend may replace it with a `spill://…` URI plus a read-only filesystem bridge, so consumers treat it as opaque.
+`SpillLocator` is a [branded](core.md#branded-ids) model-facing handle returned by the backend. The local backend renders it as a filesystem path; a remote or database backend can render a URI, key, or command token. Consumers treat it as opaque and render it with `retrievalHint` instead of assuming `read` is always the right retrieval mechanism.
```ts type-equiv
-type SpillPath = Branded<'SpillPath'>
+type SpillLocator = Branded<'SpillLocator'>
```
## The service
-`SpillFiles` (`ctx.spillFiles`, defined in [`packages/spill/spill/src/index.ts`](../../packages/spill/spill/src/index.ts)) is a one-method abstract service: `saveText(input) → Promise`. It persists the FULL `content`, chooses a private (not world-readable) location and a collision-free name derived from — never equal to — `suggestedName`, and REJECTS on a real storage failure (permissions, ENOSPC, backend unavailable). The seam owns storage only: no retention policy, no tool-result replacement, no file inspection.
+`SpillStore` (`ctx.spillStore`, defined in [`packages/spill/spill/src/index.ts`](../../packages/spill/spill/src/index.ts)) is a one-method abstract service: `saveText(input) → Promise`. It persists the FULL `content` and REJECTS on a real storage failure (permissions, ENOSPC, backend unavailable). The seam owns storage only: no retention policy, no tool-result replacement, no retrieval/search API.
-The local backend ([dsh-spill-local](../../packages/spill/spill-local)) writes under `/session-/-` — a configured or lazily-created private (0700) root, a `sha256(sessionId)` session subdir, and an exclusive owner-only (`open(path, 'wx', 0o600)`) write so a planted symlink cannot redirect it. The policy consumer ([dsh-spill-policy](../../packages/spill/spill-policy)) replaces an over-`maxInlineBytes` plain-text final result with a retention-library head/tail preview plus the spill path, best-effort: a save failure keeps the original inline result rather than turning a successful call into an `isError`.
+The local backend ([dsh-spill-local](../../packages/spill/spill-local)) writes under `/session-/-` — a configured or lazily-created private (0700) root, a `sha256(sessionId)` session subdir, and an exclusive owner-only (`open(path, 'wx', 0o600)`) write so a planted symlink cannot redirect it. Its `locator` is the local path and its `retrievalHint` tells the model to use `read` or `grep` on that path. The policy consumer ([dsh-spill-policy](../../packages/spill/spill-policy)) replaces an over-`maxInlineBytes` plain-text final result with a retention-library head/tail preview plus the spill reference, best-effort: a save failure keeps the original inline result rather than turning a successful call into an `isError`.
diff --git a/docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md b/docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md
index f261ebf84e..ac56c151fb 100644
--- a/docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md
+++ b/docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md
@@ -16,18 +16,18 @@ A thin spill storage seam plus a default spill policy plugin, in a new `packages
| Package | Role |
|---|---|
-| `@deepseek-ai/dsh-spill` | Interface: `ctx.spillFiles`, vocabulary types, no filesystem implementation. |
+| `@deepseek-ai/dsh-spill` | Interface: `ctx.spillStore`, vocabulary types, no storage implementation. |
| `@deepseek-ai/dsh-spill-local` | Local backend: private, session-scoped file storage on the host filesystem. |
-| `@deepseek-ai/dsh-spill-policy` | Tool-result policy plugin: wraps final text results after dispatch and replaces oversized results with a retained preview plus a spill-file path. |
+| `@deepseek-ai/dsh-spill-policy` | Tool-result policy plugin: wraps final text results after dispatch and replaces oversized results with a retained preview plus a spill locator. |
-There is no dedicated model-facing consumer package. The consumer is the existing `ctx.tools` execution pipeline: `dsh-spill-policy` consumes final tool results through the `tools/post-execute` waterfall, and the model uses the existing `read` tool to inspect the returned path.
+There is no dedicated model-facing consumer package. The consumer is the existing `ctx.tools` execution pipeline: `dsh-spill-policy` consumes final tool results through the `tools/post-execute` waterfall, and the model follows the backend-supplied retrieval hint for the returned locator.
### Spill seam
-The storage seam is minimal: save text and return a local path.
+The storage seam is minimal: save text and return a locator plus retrieval hint.
```ts ignore-check
-interface SpillFiles {
+interface SpillStore {
saveText(input: SaveTextSpill): Promise
}
@@ -44,19 +44,18 @@ interface SaveTextSpill {
content: string
}
-type SpillPath = Branded<'SpillPath'>
+type SpillLocator = Branded<'SpillLocator'>
interface SpillRef {
- path: SpillPath
+ locator: SpillLocator
bytes: number
+ retrievalHint: string
}
```
-`SpillPath` is a [branded](../../../../packages/util/brand) local filesystem path returned by the backend and intended for `read`. The brand records that the path came from the spill seam (a runtime artifact); it is rendered to the model as an ordinary path string in v1. `SpillOwner` scopes storage to a `SessionId` — spill is inherently session-scoped, so the seam imports `dsh-session`'s `SessionId` directly rather than minting a decoupled token like the bash executor's `OwnerToken`.
+`SpillLocator` is a [branded](../../../../packages/util/brand) model-facing handle returned by the backend. The local backend renders it as a filesystem path; a remote or database backend can render a URI, key, or command token. Consumers treat it as opaque and render it with `retrievalHint` instead of assuming `read` is always the right retrieval mechanism. `SpillOwner` scopes storage to a `SessionId` — spill is inherently session-scoped, so the seam imports `dsh-session`'s `SessionId` directly rather than minting a decoupled token like the bash executor's `OwnerToken`.
-`dsh-spill-local` owns only storage details: session-scoped directory selection, safe names, path-traversal protection, the write, and returning `{ path, bytes }`. It does not own retention policy, model-facing wording, tool-result replacement, search, or file inspection. Files land at `/session-/-`, where `root` is a configured path or a lazily-created private (0700) per-process temp dir, the session subdir is a short `sha256(sessionId)` prefix, and the leaf is a random hex prefix plus the caller's `suggestedName` sanitized to one path segment (mirrors the JSONL backend's `encodeSegment`). The write is `open(path, 'wx', 0o600)` — exclusive and owner-only, so a planted symlink cannot redirect it.
-
-The v1 local backend returns a real local `path` readable by the existing `read` tool. A future remote or virtual backend may replace this with a `spill://...` URI plus a read-only filesystem bridge; v1 keeps the interface path-shaped until that backend exists.
+`dsh-spill-local` owns only storage details: session-scoped directory selection, safe names, path-traversal protection, the write, and returning `{ locator, bytes, retrievalHint }`. It does not own retention policy, tool-result replacement, search, or file inspection. Files land at `/session-/-`, where `root` is a configured path or a lazily-created private (0700) per-process temp dir, the session subdir is a short `sha256(sessionId)` prefix, and the leaf is a random hex prefix plus the caller's `suggestedName` sanitized to one path segment (mirrors the JSONL backend's `encodeSegment`). The write is `open(path, 'wx', 0o600)` — exclusive and owner-only, so a planted symlink cannot redirect it. The locator is the path, and the retrieval hint tells the model it can use `read` or `grep` on that path.
### Spill policy
@@ -74,8 +73,8 @@ When `maxInlineBytes` is omitted the plugin registers nothing (a true no-op). Wh
1. Let the tool run normally, delegating via `next()` so a downstream listener settles the result first.
2. Flatten the accepted final `ContentBlock[]` only when it is entirely plain text; a result with any non-text block is left untouched.
3. If its UTF-8 byte size is at or below `maxInlineBytes`, leave it unchanged.
-4. If it is larger, call `ctx.spillFiles.saveText()` with the full final text.
-5. Replace the model-facing result with a retained head/tail preview plus the spill path.
+4. If it is larger, call `ctx.spillStore.saveText()` with the full final text.
+5. Replace the model-facing result with a retained head/tail preview plus the spill reference.
The preview is an implementation default owned by the policy: a head/tail split of `maxInlineBytes` via the retention library's `TextRetainer`. Future config can expose preview sizing only after a second deployment needs it.
@@ -84,10 +83,10 @@ The replacement text is intentionally generic because the policy only knows the
```text
-(Omitted N bytes. Full formatted result saved to: /.../session-.../....txt. Use read with offset/limit to inspect it.)
+(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
```
-If `ctx.spillFiles.saveText()` fails (permissions, ENOSPC, backend unavailable), or the call has no session owner, or no backend is loaded, the plugin logs the reason and returns the original result unchanged. Spill failure never turns a successful tool call into an `isError` result or hides the inline result.
+If `ctx.spillStore.saveText()` fails (permissions, ENOSPC, backend unavailable), or the call has no session owner, or no backend is loaded, the plugin logs the reason and returns the original result unchanged. Spill failure never turns a successful tool call into an `isError` result or hides the inline result.
The policy skips `read` to avoid a circular `read -> spill file -> read again` loop. Additional opt-out configuration is deferred until a real second tool needs it.
@@ -129,7 +128,7 @@ This separation is important. `web-fetch-local` still owns resource caps (`maxRe
Retention is separate from spill storage:
- `@deepseek-ai/dsh-retention` owns preview mechanics (`TextRetainer`, `ItemRetainer`, and omitted metadata).
-- `@deepseek-ai/dsh-spill` owns saving final text to a session-scoped path.
+- `@deepseek-ai/dsh-spill` owns saving final text and returning a locator plus retrieval hint.
- `@deepseek-ai/dsh-spill-policy` applies the default final-result policy in the tool pipeline, composing the two.
The final-result policy cannot replace tool-owned early spill. Some useful content is not present in final `ToolExecutionResult.content`:
@@ -138,7 +137,7 @@ The final-result policy cannot replace tool-owned early spill. Some useful conte
- `subagent` final output is the child final answer, not the child rollout.
- Future tools may produce runtime artifacts that are never represented by their final `ToolExecutionResult.content`.
-Those cases can consume `ctx.spillFiles` directly in later work. They are not part of the first showcase.
+Those cases can consume `ctx.spillStore` directly in later work. They are not part of the first showcase.
## Non-goals
@@ -154,13 +153,12 @@ Those cases can consume `ctx.spillFiles` directly in later work. They are not pa
- `saveFile()` / `linkOrCopy` for existing executor spill files, needed for bash normalization.
- Tool-owned spill for subagent rollouts (`await run.result`, read in-process child session before `run.dispose()`, save JSONL).
- Per-tool opt-out or per-tool policy declarations if the built-in `read` skip is insufficient.
-- A virtual `spill://` URI and read-only filesystem bridge.
-- Remote storage backends for ACP or remote environments where a local path is not meaningful.
+- Remote or database storage backends for ACP or remote environments where a local path is not meaningful.
- Cleanup and retention policy for old spill files, likely tied to session cleanup.
## Testing
-- `dsh-spill` unit tests pin the seam contract: registration as `ctx.spillFiles`, one-implementation-per-context, and disposal release.
+- `dsh-spill` unit tests pin the seam contract: registration as `ctx.spillStore`, one-implementation-per-context, and disposal release.
- `dsh-spill-local` unit tests cover `saveText`, `encodeSegment` sanitization (separators/tilde/whole-segment dots/empty), the session-hash directory, owner-only permissions, distinct paths per save, the configured/private root, and a storage-failure rejection.
- `dsh-spill-policy` unit tests drive real tools through `ctx.tools.execute`: disabled-mode no-op, oversized-text replacement, small/non-text passthrough, `read` skip, best-effort fallback (save failure / no backend / no owner), and downstream-composition (bounding a replaced result, preserving `additionalContext`).
- `dsh-tool-web` integration drives `web_fetch` through `ctx.tools.execute` with the real `spill-local` backend + policy, proving the model-facing text changes only by the deliberate spill notice while the spill file holds the full formatted result.
@@ -170,9 +168,9 @@ Those cases can consume `ctx.spillFiles` directly in later work. They are not pa
The default policy only sees final formatted text. It cannot preserve provider-internal content that was already capped or runtime artifacts that were never part of the result. This is acceptable for the first cut because the showcase is final-result spill, not early spill; tool-owned early spill remains deferred work.
-Returning real paths from the local backend keeps v1 simple and matches proven agent-tool behavior, but it exposes implementation paths to the model and may not work for remote backends. The interface should be revisited when a virtual or remote spill backend exists.
+Returning real paths from the local backend keeps v1 simple and matches proven agent-tool behavior, while the seam itself only promises an opaque locator plus retrieval hint so remote backends can return non-file locators.
-The v1 value proposition depends on the existing `read` tool being able to inspect the returned local path, even when the spill directory is outside the session cwd. That holds today because the filesystem policy records observations and write guards but does not confine reads to the workspace. A future workspace-confinement policy must either allow spill paths explicitly or provide a read-only spill bridge, or the spill notice would point at an unreadable path.
+The local-backend value proposition depends on the existing `read`/`grep` tools being able to inspect the returned local path, even when the spill directory is outside the session cwd. That holds today because the filesystem policy records observations and write guards but does not confine reads to the workspace. A future workspace-confinement policy must either allow local spill paths explicitly or use a non-file spill backend whose retrieval hint points at a supported reader.
**Snapshot gap.** No ACP snapshot scenario covers the transcript-visible `web_fetch` spill notice yet. The ACP snapshot harness replays keyless and cannot hit the live web, and a `web_fetch` spill requires a real over-cap HTTP body; a deterministic scenario would need a seeded loopback fetch target the replay tree does not currently wire (the examples do not load `tool-web` at all). The behavior is covered instead by the `dsh-tool-web` integration test against a loopback server. Closing the gap is follow-up work: wire `tool-web` + a seeded fetch target into the ACP example, then record a `web-fetch-spill` scenario.
@@ -182,7 +180,7 @@ The policy can become too large if it starts owning tool-specific semantics. It
**Require each tool to opt in with a retention declaration.** Rejected for v1: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single `maxInlineBytes` deployment knob is enough to prove the shape.
-**Make `tool-results` a broad tool-result platform.** Rejected: a broad package name invites retention policy, result replacement, preview wording, search, and early spill into one seam. The shared storage part is smaller: save text and return a path.
+**Make `tool-results` a broad tool-result platform.** Rejected: a broad package name invites retention policy, result replacement, preview wording, search, and early spill into one seam. The shared storage part is smaller: save text and return a locator plus retrieval hint.
**Use `ctx.fs.writeText` or the model-facing `write` tool.** Rejected: workspace filesystem writes carry project-file semantics, write/edit policy, observation state, and user-facing side effects. Spill files are runtime artifacts, not model-authored workspace edits. The existing `read` tool may inspect them later, but creation belongs to the runtime spill seam.
diff --git a/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md b/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md
index 74648ab40d..bc8a452c91 100644
--- a/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md
+++ b/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md
@@ -16,7 +16,7 @@ The tools do not use `ctx.bash.start()` and do not create model-visible backgrou
The tools align `path` with Claude Code's search tools while binding resolution to the bash workdir, not to `ctx.fs`. The tool derives the bash request workdir from `exec.agent?.session.header.cwd`, mirroring `dsh-tool-bash` and `dsh-tool-fs`; when no session cwd exists, it omits `request.workdir` so the bash implementation applies its configured cwd or process cwd through `resolve()`. For `grep`, `path` is an optional ripgrep target and may be a file or directory; omitted means the resolved bash workdir. For `glob`, `path` is an optional directory search root; omitted means the resolved bash workdir. Relative `path` values resolve against that workdir. Returned paths are displayed relative to the resolved bash workdir when possible and are intended to be follow-up-readable only in co-located deployments where the bash workdir and filesystem `read` root are the same workspace. v1 documents that deployment requirement but does not perform runtime cross-service validation. Remote or virtual filesystem search is deferred until there is a shared workspace/root contract or a provider-specific search backend.
-The package does not inject `fs`. It injects `tools`, `systemPrompt`, and `bash`; it deliberately reads `spillFiles` with `ctx.get('spillFiles')` instead of static inject because formatted-result spill is optional. Existing `@deepseek-ai/dsh-tool-fs` deployments that only want `read` / `write` / `edit` do not need to load bash.
+The package does not inject `fs`. It injects `tools`, `systemPrompt`, and `bash`; it deliberately reads `spillStore` with `ctx.get('spillStore')` instead of static inject because formatted-result spill is optional. Existing `@deepseek-ai/dsh-tool-fs` deployments that only want `read` / `write` / `edit` do not need to load bash.
### Package shape
@@ -63,9 +63,9 @@ Routine budgets stay out of the model-facing schema. `@deepseek-ai/dsh-tool-fs-s
| `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout the tool will parse; matches Claude Code's ripgrep raw buffer. |
| `timeoutMs` | `30000` | Tool-call timeout attached to both tool definitions and enforced by `@deepseek-ai/dsh-timeout-policy`. |
-`globMaxResults` and `grepMaxMatches` use `ItemRetainer({ kind: 'head' })`. `grepMaxLineBytes` uses `TextRetainer({ kind: 'head', maxBytes: grepMaxLineBytes })` for each matched line so preview cuts preserve UTF-8 boundaries. This follows the [tool result retention library](../../implemented/architecture/2026-07-06-tool-result-retention-library.md) mapping for discovery items: collect the complete result, retain head items inline, and keep path mapping, grouping, and per-line preview outside the retainer. `grep` does not expose `case_insensitive`, `head_limit`, `offset`, `count`, multiline, context lines, output modes, or file type filters in v1. A model that needs surrounding context reads the matched file with `read`; a model that needs later results reads the formatted spill file with `read offset/limit`.
+`globMaxResults` and `grepMaxMatches` use `ItemRetainer({ kind: 'head' })`. `grepMaxLineBytes` uses `TextRetainer({ kind: 'head', maxBytes: grepMaxLineBytes })` for each matched line so preview cuts preserve UTF-8 boundaries. This follows the [tool result retention library](../../implemented/architecture/2026-07-06-tool-result-retention-library.md) mapping for discovery items: collect the complete result, retain head items inline, and keep path mapping, grouping, and per-line preview outside the retainer. `grep` does not expose `case_insensitive`, `head_limit`, `offset`, `count`, multiline, context lines, output modes, or file type filters in v1. A model that needs surrounding context reads the matched file with `read`; a model that needs later results follows the returned spill locator's retrieval hint.
-The Claude Code values are reference points for the two-layer budget, not model-facing schema precedent. Its dedicated search tools buffer raw ripgrep output up to 20 MB for internal processing, use a 20-second ripgrep timeout on non-WSL platforms (60 seconds on WSL), then apply search-specific caps before the model sees a result: `GrepTool` defaults to `head_limit = 250` and persists formatted results above 20,000 characters, while `GlobTool` defaults to 100 paths and persists formatted results above 100,000 characters. This RFC mirrors the raw-buffer and inline-count defaults, chooses a 30-second default search timeout, and uses this harness's `ctx.spillFiles.saveText()` path for formatted-result recovery.
+The Claude Code values are reference points for the two-layer budget, not model-facing schema precedent. Its dedicated search tools buffer raw ripgrep output up to 20 MB for internal processing, use a 20-second ripgrep timeout on non-WSL platforms (60 seconds on WSL), then apply search-specific caps before the model sees a result: `GrepTool` defaults to `head_limit = 250` and persists formatted results above 20,000 characters, while `GlobTool` defaults to 100 paths and persists formatted results above 100,000 characters. This RFC mirrors the raw-buffer and inline-count defaults, chooses a 30-second default search timeout, and uses this harness's `ctx.spillStore.saveText()` path for formatted-result recovery.
The `path` field follows the same split as Claude Code: `grep.path` is a file-or-directory ripgrep target, while `glob.path` is a directory search root. v1 does not expose a separate cwd/workdir argument on these tools.
@@ -73,9 +73,9 @@ The `path` field follows the same split as Claude Code: `grep.path` is a file-or
### Execution
-`glob` builds a fixed `rg --files` command rooted at the resolved directory search root (`path` when supplied, else the bash workdir): `rg --files --glob --sort=modified --no-ignore --hidden`, plus VCS metadata excludes for `.git`, `.svn`, `.hg`, `.bzr`, `.jj`, and `.sl`. This aligns with Claude Code on hidden/ignored-file discovery and modified-time ordering while keeping VCS internals out of broad searches. The tool parses one path per line, maps results back to paths relative to the bash workdir when possible, pushes each path into `ItemRetainer({ kind: 'head', maxItems: globMaxResults })`, and formats the full sorted path list for a spill file when the retained result is capped.
+`glob` builds a fixed `rg --files` command rooted at the resolved directory search root (`path` when supplied, else the bash workdir): `rg --files --glob --sort=modified --no-ignore --hidden`, plus VCS metadata excludes for `.git`, `.svn`, `.hg`, `.bzr`, `.jj`, and `.sl`. This aligns with Claude Code on hidden/ignored-file discovery and modified-time ordering while keeping VCS internals out of broad searches. The tool parses one path per line, maps results back to paths relative to the bash workdir when possible, pushes each path into `ItemRetainer({ kind: 'head', maxItems: globMaxResults })`, and formats the full sorted path list for a spill artifact when the retained result is capped.
-`grep` builds a fixed line-oriented `rg --json` command against the supplied file/directory target (`path` when supplied, else the bash workdir) so file path, line number, and line text are parsed without colon-splitting ambiguity. It consumes `match` records, treats malformed JSON or malformed match records as `SEARCH_FAILED`, maps result paths relative to the bash workdir when possible, applies per-line preview retention with `grepMaxLineBytes`, pushes each match into `ItemRetainer({ kind: 'head', maxItems: grepMaxMatches })`, then groups only the retained preview matches by file for inline output. The spill file stores the full formatted match list, not only the omitted tail, so `read offset/limit` works against the same logical result the model saw.
+`grep` builds a fixed line-oriented `rg --json` command against the supplied file/directory target (`path` when supplied, else the bash workdir) so file path, line number, and line text are parsed without colon-splitting ambiguity. It consumes `match` records, treats malformed JSON or malformed match records as `SEARCH_FAILED`, maps result paths relative to the bash workdir when possible, applies per-line preview retention with `grepMaxLineBytes`, pushes each match into `ItemRetainer({ kind: 'head', maxItems: grepMaxMatches })`, then groups only the retained preview matches by file for inline output. The spill artifact stores the full formatted match list, not only the omitted tail, so the retrieval hint points at the same logical result the model saw.
Raw `rg` stdout is an internal transport detail. The tool requests `stdoutMaxBytes: rawOutputMaxBytes` through `ctx.bash.resolve()` and parses `stdout.text` only when the executor returns untruncated stdout within that cap. If stdout is larger than `rawOutputMaxBytes`, or the executor still returns `stdout.truncated`, the tool fails with a clear search error telling the model to narrow `pattern`, `path`, or `include`. The tool never exposes raw `rg` output or bash raw spill paths to the model.
@@ -87,13 +87,13 @@ Search failures use a package-owned `HarnessError` subclass with `SEARCH_*` code
### Formatted result spill
-`ctx.spillFiles` is optional and used only for model-facing formatted results. This is the first tool-owned spill call pattern in the codebase, and it is intentional because search retention is item-level policy: `globMaxResults` caps paths and `grepMaxMatches` caps matches while the tool still holds the complete logical result. The generic `dsh-spill-policy` caps final text bytes on `tools/post-execute`; by then a search tool would already have omitted later paths or matches, so the policy cannot recover them.
+`ctx.spillStore` is optional and used only for model-facing formatted results. This is the first tool-owned spill call pattern in the codebase, and it is intentional because search retention is item-level policy: `globMaxResults` caps paths and `grepMaxMatches` caps matches while the tool still holds the complete logical result. The generic `dsh-spill-policy` caps final text bytes on `tools/post-execute`; by then a search tool would already have omitted later paths or matches, so the policy cannot recover them.
-When a search produces more logical results than the inline cap and `ctx.spillFiles` is present, the tool saves the complete formatted result with `saveText()`. The spill owner is the calling agent's session header id (`exec.agent?.session.header.id`); without that owner, the search keeps the inline result and reports that the complete result could not be saved. The spill source is the tool execution identity: `{ toolName: exec.name, callId: exec.callId, label: 'result' }`. The suggested filenames are `grep-results.txt` and `glob-results.txt`; the spill backend still sanitizes them as hints, never paths.
+When a search produces more logical results than the inline cap and `ctx.spillStore` is present, the tool saves the complete formatted result with `saveText()`. The spill owner is the calling agent's session header id (`exec.agent?.session.header.id`); without that owner, the search keeps the inline result and reports that the complete result could not be saved. The spill source is the tool execution identity: `{ toolName: exec.name, callId: exec.callId, label: 'result' }`. The suggested filenames are `grep-results.txt` and `glob-results.txt`; the spill backend still treats them as hints, never paths.
When spill storage is absent, the call has no session owner, or saving fails, the tool still returns the inline page and a footer explaining that the complete result could not be saved. Search success must not turn into an `isError` result solely because formatted-result spill storage is unavailable.
-The bash raw output stream and the formatted search spill file are different artifacts. Raw `rg` stdout is parsed only in memory within the requested bash stdout cap; the formatted spill file is the stable model-facing recovery path produced by `ctx.spillFiles.saveText()`.
+The bash raw output stream and the formatted search spill artifact are different artifacts. Raw `rg` stdout is parsed only in memory within the requested bash stdout cap; the formatted spill artifact is the stable model-facing recovery locator produced by `ctx.spillStore.saveText()`.
### Result shape
@@ -102,7 +102,7 @@ A capped `glob` result with successful formatted spill returns the inline page a
```text
-(Showing N of M paths. Full sorted result saved to: /.../session-abc123/9f8e7d-glob-results.txt. Use read with offset/limit to inspect it.)
+(Showing N of M paths. Full sorted result stored at: /.../session-abc123/9f8e7d-glob-results.txt. Use read with offset/limit, or grep this path to search within it.)
```
A capped `grep` result with successful formatted spill returns grouped preview matches and a spill notice:
@@ -113,10 +113,10 @@ Found N of M matches
Line 12: ...
-(Full grep result saved to: /.../session-abc123/9f8e7d-grep-results.txt. Use read with offset/limit to inspect it.)
+(Full grep result stored at: /.../session-abc123/9f8e7d-grep-results.txt. Use read with offset/limit, or grep this path to search within it.)
```
-If the complete logical result fits under the inline cap, no formatted spill file is created. If the complete logical result is too large but formatted spill is unavailable, the footer says that the result was capped and the complete result could not be saved. The `truncated` / omitted count is a budget fact, not an incomplete-search fact; timeout, invalid regex, missing `rg`, inaccessible workdirs, raw-output overflow, binary skips, and parse failures stay in tool-domain error or incomplete fields.
+If the complete logical result fits under the inline cap, no formatted spill artifact is created. If the complete logical result is too large but formatted spill is unavailable, the footer says that the result was capped and the complete result could not be saved. The `truncated` / omitted count is a budget fact, not an incomplete-search fact; timeout, invalid regex, missing `rg`, inaccessible workdirs, raw-output overflow, binary skips, and parse failures stay in tool-domain error or incomplete fields.
## Alternatives considered
@@ -126,15 +126,15 @@ If the complete logical result fits under the inline cap, no formatted spill fil
**Use `ctx.bash.start()` for streaming early stop.** Rejected: `start()` creates model-visible background task semantics: task ids, owner tokens, `bash_output`, `bash_kill`, completion notifications, and no built-in timeout. `grep` needs a foreground tool result, not a background bash workflow. If streaming search becomes necessary, the right abstraction is a foreground streaming process handle on the bash/process seam, not borrowing the public background-task API.
-**Expose bash raw spill paths to the model.** Rejected: a bash raw spill path contains raw `rg` stdout (`rg --json` records for grep), not the stable formatted search result. Search parses raw stdout only as an internal transport; model recovery uses a formatted result saved through `ctx.spillFiles.saveText()`.
+**Expose bash raw spill paths to the model.** Rejected: a bash raw spill path contains raw `rg` stdout (`rg --json` records for grep), not the stable formatted search result. Search parses raw stdout only as an internal transport; model recovery uses a formatted result saved through `ctx.spillStore.saveText()`.
-**Add `spillFiles.saveFile()` for bash output normalization first.** Rejected for this RFC's v1: `saveFile()` would help a future bash normalization pass move existing executor spill files into session-scoped spill storage, but search only needs bounded in-memory raw `rg` stdout before producing the model-facing artifact. `saveText()` is sufficient for the formatted search result.
+**Add `spillStore.saveFile()` for bash output normalization first.** Rejected for this RFC's v1: `saveFile()` would help a future bash normalization pass move existing executor spill files into session-scoped spill storage, but search only needs bounded in-memory raw `rg` stdout before producing the model-facing artifact. `saveText()` is sufficient for the formatted search result.
**Rely on the generic `dsh-spill-policy`.** Rejected: generic post-execute spill sees only the final tool result. If `grep` / `glob` return the first page inline, the generic policy cannot recover omitted results. The search tools must save the complete formatted result themselves before returning the bounded model-facing text.
-**Expose Claude Code's full `GrepTool` schema.** Rejected for v1: `output_mode`, context flags, multiline, `head_limit`, `offset`, `case_insensitive`, and type filters make the model-facing surface into a ripgrep wrapper. This harness keeps routine budgets and continuation mechanics in deployment policy and spill files.
+**Expose Claude Code's full `GrepTool` schema.** Rejected for v1: `output_mode`, context flags, multiline, `head_limit`, `offset`, `case_insensitive`, and type filters make the model-facing surface into a ripgrep wrapper. This harness keeps routine budgets and continuation mechanics in deployment policy and spill artifacts.
-**Keep early-stop search and skip formatted spill files.** Rejected for this proposal: early stop is more efficient but gives the model no path to inspect later results. The chosen v1 optimizes result recoverability and implementation simplicity, with `timeoutMs`, `rawOutputMaxBytes`, bash backend caps, and formatted spill files as safety backstops.
+**Keep early-stop search and skip formatted spill artifacts.** Rejected for this proposal: early stop is more efficient but gives the model no path to inspect later results. The chosen v1 optimizes result recoverability and implementation simplicity, with `timeoutMs`, `rawOutputMaxBytes`, bash backend caps, and formatted spill artifacts as safety backstops.
**Expand the bash seam with a raw-output reader first.** Rejected: a portable `readRawOutput(ref, maxBytes)` API would add reference lifetime, permission, and backend storage semantics. A per-run `stdoutMaxBytes` request is the narrower seam: search either receives complete stdout within `rawOutputMaxBytes` or fails clearly.
@@ -148,11 +148,11 @@ If the complete logical result fits under the inline cap, no formatted spill fil
## Consequences
-- `glob` and `grep` are model-facing tools in `@deepseek-ai/dsh-tool-fs-search`, not `ctx.fs` provider methods and not part of the existing `@deepseek-ai/dsh-tool-fs` root plugin. The package injects `tools`, `systemPrompt`, and `bash`; it does not inject `fs`, and `ctx.spillFiles` stays optional via `ctx.get('spillFiles')`.
+- `glob` and `grep` are model-facing tools in `@deepseek-ai/dsh-tool-fs-search`, not `ctx.fs` provider methods and not part of the existing `@deepseek-ai/dsh-tool-fs` root plugin. The package injects `tools`, `systemPrompt`, and `bash`; it does not inject `fs`, and `ctx.spillStore` stays optional via `ctx.get('spillStore')`.
- The schemas are exactly `glob(pattern, path?)` and `grep(pattern, path?, include?)`; search caps and timeout are defaulted, validated Config fields (`globMaxResults`, `grepMaxMatches`, `grepMaxLineBytes`, `rawOutputMaxBytes`, `timeoutMs`).
- The tools execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)`, forward `exec.signal`, never call `ctx.bash.start()`, and never expose a bash task id. The bash request workdir comes from `exec.agent?.session.header.cwd` when available; the resolved `spec.workdir` drives execution and relative-path display.
- The tools request `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam, parse only untruncated stdout within that cap, and treat over-cap or still-truncated raw output as a clear search failure; raw `rg` output is never exposed to the model.
-- Oversized complete formatted results are saved through `ctx.spillFiles.saveText()` when available while inline results stay bounded; spill failure, a missing backend, or a missing owner preserves the inline result and reports the unsaved remainder — never an `isError`.
+- Oversized complete formatted results are saved through `ctx.spillStore.saveText()` when available while inline results stay bounded; spill failure, a missing backend, or a missing owner preserves the inline result and reports the unsaved remainder — never an `isError`.
- The package README, the generated config catalog, and exported JSDoc document the Config fields and `SEARCH_*` codes; the coding-agent example ships the tools (the acp-agent tree waits on the snapshot re-record above); the fs group README records the co-located bash/filesystem deployment requirement.
## Risks
@@ -163,4 +163,4 @@ Shell command construction is the sharpest safety edge. Because `ctx.bash` accep
The v1 assumes a co-located bash/filesystem deployment. If bash searches one workspace and the `read` tool resolves paths against another, returned paths may not be follow-up-readable. The package documents this requirement but does not verify it at runtime.
-Spill paths are local filesystem paths in v1. The formatted-result design works for local deployments where `read` can open spill files; remote or workspace-confined deployments need either an allowlist for spill paths or a future virtual spill URI bridge.
+Spill locators are backend-owned. The current local backend returns local filesystem paths and works in deployments where `read`/`grep` can open those files; remote or workspace-confined deployments can use a backend whose locator and retrieval hint point at a supported retrieval mechanism.
diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md
index bb5abbcbc5..7778ac1e26 100644
--- a/docs/tool-catalog.md
+++ b/docs/tool-catalog.md
@@ -20,7 +20,7 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. |
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. |
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
-| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillFiles backend; returned paths are follow-up-readable in co-located bash/filesystem deployments. |
+| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - |
| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. |
| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. |
@@ -428,7 +428,7 @@ Search file contents with a ripgrep regular expression. Returns matching lines w
Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-search/src/index.ts)
-glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillFiles backend; returned paths are follow-up-readable in co-located bash/filesystem deployments.
+glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.
## `@deepseek-ai/dsh-tool-skill`
diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl
index aa60d5143e..bc839dd9e2 100644
--- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl
@@ -10,7 +10,7 @@
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}}
-{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1391 bytes. Full formatted result saved to: {{spillPath:bash.txt}}. Use read with offset/limit to inspect it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
+{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: {{spillLocator:bash.txt}}. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
diff --git a/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl
index df47558232..b3590d29bb 100644
--- a/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl
+++ b/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl
@@ -1,6 +1,6 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_spill","title":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","kind":"execute","status":"in_progress","rawInput":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","content":[{"type":"content","content":{"type":"text","text":"Print large deterministic output"}}]}}}
-{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1391 bytes. Full formatted result saved to: {{spillPath:bash.txt}}. Use read with offset/limit to inspect it.)\n```"}}]}}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: {{spillLocator:bash.txt}}. Use read with offset/limit, or grep this path to search within it.)\n```"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml
index cdff07ae00..b23d785439 100644
--- a/examples/coding-agent/cordis.yml
+++ b/examples/coding-agent/cordis.yml
@@ -135,7 +135,7 @@
# Bash-backed discovery tools (glob/grep): fixed ripgrep commands through the
# local bash executor above — not ctx.fs. Capped results save the complete
-# formatted list through the spill backend below (ctx.spillFiles, optional).
+# formatted list through the spill backend below (ctx.spillStore, optional).
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
@@ -148,9 +148,9 @@
# Tool-output spill stack: a local backend that saves oversized tool text under
# a private session-scoped dir, and the tools/post-execute policy that replaces
-# an over-budget plain-text result with a preview + the spill path (the model
-# reads the full result later). A leaf pair after the app (needs ctx.tools). The
-# policy is a no-op until a tool returns more than maxInlineBytes of plain text.
+# an over-budget plain-text result with a preview + the spill locator/retrieval
+# hint. A leaf pair after the app (needs ctx.tools). The policy is a no-op until
+# a tool returns more than maxInlineBytes of plain text.
- id: spill-local
name: '@deepseek-ai/dsh-spill-local'
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index 18cca1f382..e9f8b76bcc 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -173,7 +173,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
],
},
{
- key: 'spillFiles',
+ key: 'spillStore',
summary: 'Abstract spill storage service.',
methods: [
'abstract saveText(input: SaveTextSpill): Promise',
@@ -818,17 +818,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SkillSummary',
declaration: 'export interface SkillSummary {\n name: string;\n description: string;\n whenToUse?: string;\n disableModelInvocation?: boolean;\n source: SkillSource;\n provider: string;\n resourceBase?: SkillResourceBase;\n}',
},
+ {
+ name: 'SpillLocator',
+ declaration: 'export type SpillLocator = Branded<\'SpillLocator\'>;',
+ },
{
name: 'SpillOwner',
declaration: 'export interface SpillOwner {\n sessionId: SessionId;\n}',
},
- {
- name: 'SpillPath',
- declaration: 'export type SpillPath = Branded<\'SpillPath\'>;',
- },
{
name: 'SpillRef',
- declaration: 'export interface SpillRef {\n path: SpillPath;\n bytes: number;\n}',
+ declaration: 'export interface SpillRef {\n locator: SpillLocator;\n bytes: number;\n retrievalHint: string;\n}',
},
{
name: 'SpillSource',
diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md
index 469830683d..2f9df200de 100644
--- a/packages/fs/tool-fs-search/README.md
+++ b/packages/fs/tool-fs-search/README.md
@@ -1,13 +1,13 @@
# @deepseek-ai/dsh-tool-fs-search
-The **model-facing filesystem discovery tools** — `glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillFiles` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
+The **model-facing filesystem discovery tools** — `glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
```ts ignore-check
// Default deployment: a bash executor, then the discovery tools.
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
await ctx.plugin(ToolFsSearch) // this package — registers glob/grep
// Optional: a spill backend makes capped results fully recoverable.
-await ctx.plugin(LocalSpillFiles) // @deepseek-ai/dsh-spill-local
+await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
```
Why bash-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution (local, sandboxed, remote); this package owns schemas, argument validation, shell quoting, parsing, retention, formatted-result spill, and timeout declaration. The tools never call `ctx.bash.start()` and never expose a bash task id — the call returns only after `rg` exits, times out, is aborted, or fails.
@@ -22,8 +22,8 @@ All keys are optional; the defaults are the shipped search caps.
| Key | Default | Meaning |
|---|---|---|
-| `globMaxResults` | `100` | Max paths one `glob` call retains inline (matches Claude Code's `GlobTool` limit); later paths go to the formatted spill file. |
-| `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill file. |
+| `globMaxResults` | `100` | Max paths one `glob` call retains inline (matches Claude Code's `GlobTool` limit); later paths go to the formatted spill artifact. |
+| `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. |
| `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. |
| `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. |
| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the bash backend's own timeout stays a second safety cap. |
@@ -35,11 +35,11 @@ All keys are optional; the defaults are the shipped search caps.
| `glob` | `pattern`, `path?` | `rg --files --glob --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one path per line, modification-time ordered. |
| `grep` | `pattern`, `path?`, `include?` | Line-oriented `rg --json` parse (no colon-splitting ambiguity). `pattern` is a ripgrep regex; `path` is an optional **file or directory** target; `include` is ONE positive glob filter — a comma-separated list or a negated (`!…`) value is rejected up front (brace alternation like `*.{ts,tsx}` is fine). Returns matches grouped by file as `Line N: `. |
-Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results reads the formatted spill file with `read offset/limit`.
+Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results follows the returned spill locator's retrieval hint.
## Two budgets, two artifacts
-Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. The model-facing recovery artifact is different: when a search yields more logical results than the inline cap, the tool saves the COMPLETE formatted result through `ctx.spillFiles.saveText()` (suggested names `glob-results.txt` / `grep-results.txt`, owner = the calling session, source = the tool execution identity) and appends a footer naming the saved path. This is the first tool-owned spill call in the codebase — deliberate, because retention here is item-level: the generic `@deepseek-ai/dsh-spill-policy` only sees the final text on `tools/post-execute`, by which point a capped search has already omitted later paths/matches. A missing spill backend, a call with no session owner, or a `saveText()` failure keeps the inline page and reports that the complete result could not be saved — never an `isError`.
+Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. The model-facing recovery artifact is different: when a search yields more logical results than the inline cap, the tool saves the COMPLETE formatted result through `ctx.spillStore.saveText()` (suggested names `glob-results.txt` / `grep-results.txt`, owner = the calling session, source = the tool execution identity) and appends a footer naming the returned locator and retrieval hint. This is the first tool-owned spill call in the codebase — deliberate, because retention here is item-level: the generic `@deepseek-ai/dsh-spill-policy` only sees the final text on `tools/post-execute`, by which point a capped search has already omitted later paths/matches. A missing spill backend, a call with no session owner, or a `saveText()` failure keeps the inline page and reports that the complete result could not be saved — never an `isError`.
## Errors
diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts
index 09a3e1d9ce..a3e803fb50 100644
--- a/packages/fs/tool-fs-search/src/glob.ts
+++ b/packages/fs/tool-fs-search/src/glob.ts
@@ -15,6 +15,7 @@ import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { ItemRetainer } from '@deepseek-ai/dsh-retention'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
+import type { SpillRef } from '@deepseek-ai/dsh-spill'
import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
@@ -100,18 +101,18 @@ export function buildGlobCommand(input: GlobInput): string {
/**
* Format the model-facing `glob` result: the retained paths, then — when the
* result was capped — a footer carrying either the formatted-spill recovery
- * path or the could-not-save explanation. The omitted count is a budget fact:
+ * locator or the could-not-save explanation. The omitted count is a budget fact:
* the search itself completed.
*
* @param retained - the retention outcome over every discovered path.
- * @param spillPath - the saved complete-result path, or `undefined` when unsaved.
+ * @param spillRef - the saved complete-result reference, or `undefined` when unsaved.
* @returns the model-facing text.
*/
-export function formatGlobOutput(retained: RetainedItems, spillPath: string | undefined): string {
+export function formatGlobOutput(retained: RetainedItems, spillRef: SpillRef | undefined): string {
const body = retained.items.join('\n')
if (!retained.truncated) return body
- const recovery = spillPath !== undefined
- ? `Full sorted result saved to: ${spillPath}. Use read with offset/limit to inspect it.`
+ const recovery = spillRef !== undefined
+ ? `Full sorted result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`
: 'The complete result could not be saved; narrow pattern or path to see more.'
return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})`
}
@@ -168,10 +169,10 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
// The complete sorted list is the recovery artifact; save it only when
// the inline page omitted paths (an uncapped result needs no spill file).
- const spillPath = retained.truncated
+ const spillRef = retained.truncated
? await trySaveFormattedResult(ctx, exec, 'glob-results.txt', all.join('\n'))
: undefined
- return [{ type: 'text', text: formatGlobOutput(retained, spillPath) }]
+ return [{ type: 'text', text: formatGlobOutput(retained, spillRef) }]
},
presentCall: presentGlobCall,
}))
diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts
index e5e64e5222..3935513b73 100644
--- a/packages/fs/tool-fs-search/src/grep.ts
+++ b/packages/fs/tool-fs-search/src/grep.ts
@@ -16,6 +16,7 @@ import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
+import type { SpillRef } from '@deepseek-ai/dsh-spill'
import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
@@ -221,21 +222,21 @@ export function formatGrepMatches(matches: GrepMatch[]): string {
/**
* Format the model-facing `grep` result: a found-count header, the retained
* matches grouped by file, then — when the result was capped — a footer
- * carrying either the formatted-spill recovery path or the could-not-save
+ * carrying either the formatted-spill recovery locator or the could-not-save
* explanation. The omitted count is a budget fact: the search itself completed.
*
* @param retained - the retention outcome over every parsed match.
- * @param spillPath - the saved complete-result path, or `undefined` when unsaved.
+ * @param spillRef - the saved complete-result reference, or `undefined` when unsaved.
* @returns the model-facing text.
*/
-export function formatGrepOutput(retained: RetainedItems, spillPath: string | undefined): string {
+export function formatGrepOutput(retained: RetainedItems, spillRef: SpillRef | undefined): string {
const header = retained.truncated
? `Found ${retained.kept} of ${retained.seen} matches`
: `Found ${retained.seen} ${matchNoun(retained.seen)}`
const body = formatGrepMatches(retained.items)
if (!retained.truncated) return `${header}\n\n${body}`
- const recovery = spillPath !== undefined
- ? `Full grep result saved to: ${spillPath}. Use read with offset/limit to inspect it.`
+ const recovery = spillRef !== undefined
+ ? `Full grep result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`
: 'The complete result could not be saved; narrow pattern, path, or include to see more.'
return `${header}\n\n${body}\n\n(${recovery})`
}
@@ -299,7 +300,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
// The spill file stores the FULL formatted match list (same grouped,
// per-line-previewed shape the model saw), so read offset/limit pages the
// same logical result; save only when the inline page omitted matches.
- const spillPath = retained.truncated
+ const spillRef = retained.truncated
? await trySaveFormattedResult(
ctx,
exec,
@@ -307,7 +308,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
`Found ${all.length} ${matchNoun(all.length)}\n\n${formatGrepMatches(all)}`,
)
: undefined
- return [{ type: 'text', text: formatGrepOutput(retained, spillPath) }]
+ return [{ type: 'text', text: formatGrepOutput(retained, spillRef) }]
},
presentCall: presentGrepCall,
}))
diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts
index 1fec6a999e..8c33d5770a 100644
--- a/packages/fs/tool-fs-search/src/index.ts
+++ b/packages/fs/tool-fs-search/src/index.ts
@@ -13,7 +13,7 @@
* bash executor owns request defaulting/capping, subprocess execution,
* process-group termination, environment scrubbing, raw output capture, and
* backend substitution. The package injects `tools`, `systemPrompt`, and
- * `bash` — deliberately NOT `fs`, and `ctx.spillFiles` is read opportunistically
+ * `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read opportunistically
* with `ctx.get()` because formatted-result spill is optional.
*
* Returned paths are displayed relative to the resolved bash workdir and are
@@ -52,7 +52,7 @@ export { singleQuote } from './shell-quote.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tool-fs-search'
-/** Services required by the search tool suite (`spillFiles` is optional, read via `ctx.get()`). */
+/** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */
export const inject = ['tools', 'systemPrompt', 'bash']
/** Plugin config (all optional — `Config` supplies the defaults). */
diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts
index 233c1e78d4..0682c86e35 100644
--- a/packages/fs/tool-fs-search/src/search-core.ts
+++ b/packages/fs/tool-fs-search/src/search-core.ts
@@ -10,7 +10,7 @@
* detail: the tools request a per-run stdout capture budget from the bash seam,
* parse only complete in-memory stdout within `rawOutputMaxBytes`, and never
* read executor spill files. The model-facing recovery artifact is the
- * formatted result saved through `ctx.spillFiles.saveText()`
+ * formatted result saved through `ctx.spillStore.saveText()`
* ({@link trySaveFormattedResult}).
*
* @module @deepseek-ai/dsh-tool-fs-search/search-core
@@ -20,7 +20,7 @@ import { isAbsolute, relative, sep } from 'node:path'
import type { Context } from 'cordis'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
-import type { SaveTextSpill } from '@deepseek-ai/dsh-spill'
+import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
/**
@@ -214,8 +214,8 @@ export function toWorkdirRelative(path: string, workdir: string): string {
/**
* Best-effort save of one COMPLETE formatted search result through
- * `ctx.spillFiles.saveText()` — the model-facing recovery path for a capped
- * result. `spillFiles` is read with `ctx.get()` (not static inject) because
+ * `ctx.spillStore.saveText()` — the model-facing recovery path for a capped
+ * result. `spillStore` is read with `ctx.get()` (not static inject) because
* formatted-result spill is optional; the spill owner is the calling agent's
* session header id and the source is the tool execution identity. A missing
* backend, a call with no session owner, or a `saveText()` rejection logs a
@@ -223,26 +223,26 @@ export function toWorkdirRelative(path: string, workdir: string): string {
* reports that the complete result could not be saved; search success never
* turns into `isError` because spill storage is unavailable.
*
- * @param ctx - the plugin context; `spillFiles` is looked up opportunistically.
+ * @param ctx - the plugin context; `spillStore` is looked up opportunistically.
* @param exec - the tool-execution context; supplies the owning session, tool name, and call id.
* @param suggestedName - the backend-sanitized filename hint (e.g. `grep-results.txt`).
* @param content - the complete formatted result to persist.
- * @returns the saved spill path, or `undefined` when the result could not be saved.
+ * @returns the saved spill reference, or `undefined` when the result could not be saved.
*/
export async function trySaveFormattedResult(
ctx: Context,
exec: ToolExecution,
suggestedName: string,
content: string,
-): Promise {
+): Promise {
const sessionId = exec.agent?.session.header.id
if (sessionId === undefined) {
ctx.logger.warn(`tool-fs-search: no session owner for ${exec.name} result; complete result not saved`)
return undefined
}
- const spillFiles = ctx.get('spillFiles')
- if (!spillFiles) {
- ctx.logger.warn(`tool-fs-search: no ctx.spillFiles backend loaded; complete ${exec.name} result not saved`)
+ const spillStore = ctx.get('spillStore')
+ if (!spillStore) {
+ ctx.logger.warn(`tool-fs-search: no ctx.spillStore backend loaded; complete ${exec.name} result not saved`)
return undefined
}
const save: SaveTextSpill = {
@@ -252,8 +252,7 @@ export async function trySaveFormattedResult(
content,
}
try {
- const { path } = await spillFiles.saveText(save)
- return path
+ return await spillStore.saveText(save)
} catch (error: unknown) {
// Best-effort: a storage failure must never fail the search or hide the
// inline result — the footer reports the unsaved remainder instead.
diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts
index 7e7199c1be..9131940de5 100644
--- a/packages/fs/tool-fs-search/tests/tools.spec.ts
+++ b/packages/fs/tool-fs-search/tests/tools.spec.ts
@@ -17,7 +17,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
-import { SpillFiles, SpillPath } from '@deepseek-ai/dsh-spill'
+import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
import {
@@ -95,14 +95,18 @@ class FakeBash extends BashExecutor {
}
/** A recording spill backend; arm `failWith` to script a storage failure. */
-class FakeSpill extends SpillFiles {
+class FakeSpill extends SpillStore {
saves: SaveTextSpill[] = []
failWith?: Error
override saveText(input: SaveTextSpill): Promise {
if (this.failWith) return Promise.reject(this.failWith)
this.saves.push(input)
- return Promise.resolve({ path: SpillPath(`/spill/${input.suggestedName}`), bytes: Buffer.byteLength(input.content, 'utf8') })
+ return Promise.resolve({
+ locator: SpillLocator(`/spill/${input.suggestedName}`),
+ bytes: Buffer.byteLength(input.content, 'utf8'),
+ retrievalHint: 'Use the fake retrieval hint.',
+ })
}
}
@@ -119,7 +123,7 @@ async function setup(options: SetupOptions = {}) {
if (options.spill === true) await ctx.plugin(FakeSpill)
const fiber = await ctx.plugin(ToolFsSearch, options.config)
const bash = ctx.bash as FakeBash
- const spill = options.spill === true ? ctx.get('spillFiles') as FakeSpill : undefined
+ const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined
return { ctx, bash, spill, fiber }
}
@@ -445,12 +449,12 @@ describe('glob results', () => {
expect(bash.specs[0]?.command).toContain("-- 'sub'")
})
- it('caps at globMaxResults and saves the FULL sorted list through spillFiles', async () => {
+ it('caps at globMaxResults and saves the FULL sorted list through spillStore', async () => {
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true })
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
expect(result.isError).toBe(false)
- expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result saved to: /spill/glob-results.txt. Use read with offset/limit to inspect it.)')
+ expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result stored at: /spill/glob-results.txt. Use the fake retrieval hint.)')
expect(spill?.saves).toHaveLength(1)
expect(spill?.saves[0]).toMatchObject({
owner: { sessionId: 'session-1' },
@@ -543,7 +547,7 @@ describe('grep results', () => {
'',
].join('\n'))
const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') })
- expect(text(result)).toBe('Found 2 of 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\n(Full grep result saved to: /spill/grep-results.txt. Use read with offset/limit to inspect it.)')
+ expect(text(result)).toBe('Found 2 of 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\n(Full grep result stored at: /spill/grep-results.txt. Use the fake retrieval hint.)')
expect(spill?.saves[0]).toMatchObject({
source: { toolName: 'grep', label: 'result' },
suggestedName: 'grep-results.txt',
diff --git a/packages/spill/README.md b/packages/spill/README.md
index 35122275a3..7d54c91eb5 100644
--- a/packages/spill/README.md
+++ b/packages/spill/README.md
@@ -4,9 +4,9 @@ The tool-output spill capability seam: an abstract storage interface, a local fi
| Package | Role | ctx key |
|---|---|---|
-| `spill/` | Abstract spill storage seam (`saveText` — persist oversized tool text to a session-scoped path) | `ctx.spillFiles` |
-| `spill-local/` | Local-filesystem backend: private, session-scoped files with traversal-safe names | (registers on `ctx.spillFiles`) |
-| `spill-policy/` | `tools/post-execute` policy: replaces oversized plain-text results with a preview + spill path | (no service surface) |
+| `spill/` | Abstract spill storage seam (`saveText` — persist oversized tool text and return a locator + retrieval hint) | `ctx.spillStore` |
+| `spill-local/` | Local-filesystem backend: private, session-scoped files with traversal-safe names | (registers on `ctx.spillStore`) |
+| `spill-policy/` | `tools/post-execute` policy: replaces oversized plain-text results with a preview + spill locator | (no service surface) |
The interface lives at `spill/spill/`. The split mirrors bash/fs: the seam owns storage only, `spill-local` owns the filesystem mechanics, and `spill-policy` owns WHEN to spill and the model-facing notice. Preview mechanics stay in [`util/retention`](../util/README.md) — the policy composes the two without either owning the other's job.
diff --git a/packages/spill/spill-local/README.md b/packages/spill/spill-local/README.md
index 1205b31eef..6860cc7638 100644
--- a/packages/spill/spill-local/README.md
+++ b/packages/spill/spill-local/README.md
@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-spill-local
-The **local-filesystem** implementation of the [`@deepseek-ai/dsh-spill`](../spill) storage seam. Registers as `ctx.spillFiles` and persists a tool's oversized text to a private, session-scoped file the model's `read` tool can open.
+The **local-filesystem** implementation of the [`@deepseek-ai/dsh-spill`](../spill) storage seam. Registers as `ctx.spillStore` and persists a tool's oversized text to a private, session-scoped file; its locator is the file path and its retrieval hint tells the model to use `read` or `grep` on that path.
## Storage layout
diff --git a/packages/spill/spill-local/src/index.ts b/packages/spill/spill-local/src/index.ts
index 8aaf35e0a8..73e2cad851 100644
--- a/packages/spill/spill-local/src/index.ts
+++ b/packages/spill/spill-local/src/index.ts
@@ -1,9 +1,9 @@
/**
- * `LocalSpillFiles`: the host-filesystem implementation of the
+ * `LocalSpillStore`: the host-filesystem implementation of the
* `@deepseek-ai/dsh-spill` storage seam. Persists a tool's oversized text to a
* private, session-scoped file (see `./store.ts` for the traversal-safe naming
- * and exclusive owner-only write) and returns a path the local `read` tool can
- * open.
+ * and exclusive owner-only write) and returns a path locator plus local
+ * read/grep retrieval guidance.
*
* @module @deepseek-ai/dsh-spill-local
*/
@@ -11,7 +11,7 @@
import { Context } from 'cordis'
import { resolve } from 'node:path'
import z from 'schemastery'
-import { SpillFiles, SpillPath } from '@deepseek-ai/dsh-spill'
+import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
import { privateRoot, saveTextFile } from './store.ts'
@@ -34,7 +34,7 @@ export interface Config {
* (0700) root — a spilled tool result must not be readable by other local users
* or redirectable via a planted symlink.
*/
-export class LocalSpillFiles extends SpillFiles {
+export class LocalSpillStore extends SpillStore {
static Config: z = z.object({
root: z.string(),
})
@@ -54,8 +54,12 @@ export class LocalSpillFiles extends SpillFiles {
suggestedName: input.suggestedName,
content: input.content,
})
- return { path: SpillPath(saved.path), bytes: saved.bytes }
+ return {
+ locator: SpillLocator(saved.path),
+ bytes: saved.bytes,
+ retrievalHint: 'Use read with offset/limit, or grep this path to search within it.',
+ }
}
}
-export default LocalSpillFiles
+export default LocalSpillStore
diff --git a/packages/spill/spill-local/tests/spill-local.spec.ts b/packages/spill/spill-local/tests/spill-local.spec.ts
index 7357c1ede5..d73fca9fe3 100644
--- a/packages/spill/spill-local/tests/spill-local.spec.ts
+++ b/packages/spill/spill-local/tests/spill-local.spec.ts
@@ -1,9 +1,9 @@
/**
* Tests for the LOCAL spill backend: `saveText` writes a session-scoped file and
- * returns its path + byte length, filename sanitization neutralizes traversal,
- * the configured `root` is honored (and the private default when omitted), and a
- * storage failure rejects. The Cordis-free `store.ts` helpers are exercised
- * directly for the naming/encoding edge cases.
+ * returns a locator + byte length + retrieval hint, filename sanitization
+ * neutralizes traversal, the configured `root` is honored (and the private
+ * default when omitted), and a storage failure rejects. The Cordis-free
+ * `store.ts` helpers are exercised directly for the naming/encoding edge cases.
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
@@ -14,7 +14,7 @@ import { dirname, isAbsolute, join } from 'node:path'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SaveTextSpill } from '@deepseek-ai/dsh-spill'
-import LocalSpillFiles, { encodeSegment, privateRoot, saveTextFile, sessionDir } from '@deepseek-ai/dsh-spill-local'
+import LocalSpillStore, { encodeSegment, privateRoot, saveTextFile, sessionDir } from '@deepseek-ai/dsh-spill-local'
let root: string
@@ -106,33 +106,34 @@ describe('privateRoot', () => {
})
})
-describe('LocalSpillFiles service', () => {
- it('registers as ctx.spillFiles and saves under the configured root', async () => {
+describe('LocalSpillStore service', () => {
+ it('registers as ctx.spillStore and saves under the configured root', async () => {
const ctx = new Context()
- await ctx.plugin(LocalSpillFiles, { root })
- const ref = await ctx.spillFiles.saveText(request())
- expect(dirname(ref.path)).toBe(sessionDir(root, 'sess-1'))
- expect(readFileSync(ref.path, 'utf8')).toBe('the full body')
+ await ctx.plugin(LocalSpillStore, { root })
+ const ref = await ctx.spillStore.saveText(request())
+ expect(dirname(ref.locator)).toBe(sessionDir(root, 'sess-1'))
+ expect(readFileSync(ref.locator, 'utf8')).toBe('the full body')
expect(ref.bytes).toBe(Buffer.byteLength('the full body', 'utf8'))
+ expect(ref.retrievalHint).toBe('Use read with offset/limit, or grep this path to search within it.')
})
it('resolves a relative configured root to absolute', async () => {
const ctx = new Context()
- await ctx.plugin(LocalSpillFiles, { root: '.' })
- expect(isAbsolute((ctx.spillFiles as LocalSpillFiles).root)).toBe(true)
+ await ctx.plugin(LocalSpillStore, { root: '.' })
+ expect(isAbsolute((ctx.spillStore as LocalSpillStore).root)).toBe(true)
})
it('falls back to the private root when none is configured', async () => {
const ctx = new Context()
- await ctx.plugin(LocalSpillFiles, {})
- expect((ctx.spillFiles as LocalSpillFiles).root).toBe(privateRoot())
+ await ctx.plugin(LocalSpillStore, {})
+ expect((ctx.spillStore as LocalSpillStore).root).toBe(privateRoot())
})
it('rejects when the root is not writable (missing parent, exclusive open)', async () => {
const ctx = new Context()
// A file (not a dir) as the root makes mkdir under it fail — a real storage error.
const filePath = (await saveTextFile({ root, sessionId: 's', suggestedName: 'f', content: 'x' })).path
- await ctx.plugin(LocalSpillFiles, { root: filePath })
- await expect(ctx.spillFiles.saveText(request())).rejects.toThrow()
+ await ctx.plugin(LocalSpillStore, { root: filePath })
+ await expect(ctx.spillStore.saveText(request())).rejects.toThrow()
})
})
diff --git a/packages/spill/spill-policy/README.md b/packages/spill/spill-policy/README.md
index fe128de9a0..c1592e926e 100644
--- a/packages/spill/spill-policy/README.md
+++ b/packages/spill/spill-policy/README.md
@@ -1,8 +1,8 @@
# @deepseek-ai/dsh-spill-policy
-The **tool-result spill policy**: a `tools/post-execute` transformer that keeps oversized plain-text tool results out of the model's context. When a final result exceeds `maxInlineBytes`, it saves the FULL text to a session-scoped spill file via [`ctx.spillFiles`](../spill) and replaces the model-facing result with a bounded head/tail preview plus the spill path — the model reads the complete result later with the existing `read` tool.
+The **tool-result spill policy**: a `tools/post-execute` transformer that keeps oversized plain-text tool results out of the model's context. When a final result exceeds `maxInlineBytes`, it saves the FULL text through [`ctx.spillStore`](../spill) and replaces the model-facing result with a bounded head/tail preview plus the backend's locator and retrieval hint.
-This plugin registers **no service** and owns no storage or preview mechanics: preview is [`@deepseek-ai/dsh-retention`](../../util/retention) (`TextRetainer`), storage is `ctx.spillFiles`. It only decides WHEN to spill and composes the notice.
+This plugin registers **no service** and owns no storage or preview mechanics: preview is [`@deepseek-ai/dsh-retention`](../../util/retention) (`TextRetainer`), storage is `ctx.spillStore`. It only decides WHEN to spill and composes the notice.
## Config
@@ -13,7 +13,7 @@ This plugin registers **no service** and owns no storage or preview mechanics: p
## Behavior
1. Let the tool run (delegates via `next()`, so it bounds whatever a downstream hook accepted).
-2. Skip `read` (avoids a `read → spill file → read again` loop) and any non-`accept` decision (a `block`'s corrective feedback passes through).
+2. Skip `read` (avoids a `read → spill → read again` loop) and any non-`accept` decision (a `block`'s corrective feedback passes through).
3. Flatten the accepted content only when it is **plain text** (all `text` blocks); a result with any non-text block is left untouched.
4. If its UTF-8 size is `≤ maxInlineBytes`, leave it unchanged.
5. Otherwise save the full text and replace the result with a preview + this notice, sized so the whole replacement (preview + blank line + notice) stays within `maxInlineBytes` — the notice's byte cost is reserved out of the budget, so the preview shrinks to fit and the model-facing result never exceeds the cap:
@@ -21,13 +21,13 @@ This plugin registers **no service** and owns no storage or preview mechanics: p
```text
- (Omitted N bytes. Full formatted result saved to: /…/session-…/…-web_fetch.txt. Use read with offset/limit to inspect it.)
+ (Omitted N bytes. Full formatted result stored at: /…/session-…/…-web_fetch.txt. Use read with offset/limit, or grep this path to search within it.)
```
- When the notice alone fills the budget (a tiny cap or a long path) the preview is empty and only the notice is returned. If even that notice-only replacement would exceed `maxInlineBytes`, the policy keeps the inline result — it never emits a replacement over the cap (and a within-cap replacement is always smaller than the original, so this also means spilling never adds bytes).
+ When the notice alone fills the budget (a tiny cap or a long locator) the preview is empty and only the notice is returned. If even that notice-only replacement would exceed `maxInlineBytes`, the policy keeps the inline result — it never emits a replacement over the cap (and a within-cap replacement is always smaller than the original, so this also means spilling never adds bytes).
-**Best-effort:** no session owner, no `ctx.spillFiles` backend, or a `saveText` rejection ⇒ the policy logs a warning and returns the original result. A spill failure never turns a successful call into an `isError` or hides the inline result.
+**Best-effort:** no session owner, no `ctx.spillStore` backend, or a `saveText` rejection ⇒ the policy logs a warning and returns the original result. A spill failure never turns a successful call into an `isError` or hides the inline result.
## Scope
-The policy sees only the FINAL formatted tool result — not a tool's internal resource. If a provider already truncated (e.g. `web-fetch-local.maxBodyChars`), the spill file holds the full formatted result the tool returned, not the full original source. Provider/resource caps stay mandatory and separate. Tool-owned early spill (bash streams, subagent rollouts) is future work — see the [tool output spill RFC](../../../docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md).
+The policy sees only the FINAL formatted tool result — not a tool's internal resource. If a provider already truncated (e.g. `web-fetch-local.maxBodyChars`), the spill artifact holds the full formatted result the tool returned, not the full original source. Provider/resource caps stay mandatory and separate. Tool-owned early spill (bash streams, subagent rollouts) is future work — see the [tool output spill RFC](../../../docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md).
diff --git a/packages/spill/spill-policy/src/index.ts b/packages/spill/spill-policy/src/index.ts
index 0472bd9a8a..b1b16b7452 100644
--- a/packages/spill/spill-policy/src/index.ts
+++ b/packages/spill/spill-policy/src/index.ts
@@ -2,12 +2,12 @@
* The spill-policy PLUGIN: a `tools/post-execute` result transformer that keeps
* oversized plain-text tool results out of the model's context. When a final
* result's UTF-8 size exceeds `maxInlineBytes`, it saves the FULL text to a
- * session-scoped spill file (`ctx.spillFiles`) and replaces the model-facing
- * result with a bounded head/tail preview plus the spill path — the model reads
- * the complete result later with the existing `read` tool.
+ * session-scoped spill artifact (`ctx.spillStore`) and replaces the
+ * model-facing result with a bounded head/tail preview plus the backend's
+ * locator and retrieval guidance.
*
* It registers NO service and owns NO storage or preview mechanics: preview is
- * `@deepseek-ai/dsh-retention` (`TextRetainer`), storage is `ctx.spillFiles`.
+ * `@deepseek-ai/dsh-retention` (`TextRetainer`), storage is `ctx.spillStore`.
* The policy only decides WHEN to spill and composes the notice.
*
* ## Deliberately narrow
@@ -16,8 +16,8 @@
* - Plain-text results only: a result carrying any non-text block is left
* untouched (the policy knows only the final formatted text, not tool
* internals).
- * - `read` is skipped to avoid a `read → spill file → read again` loop.
- * - Best-effort: no session owner, no `ctx.spillFiles` backend, or a save
+ * - `read` is skipped to avoid a `read → spill → read again` loop.
+ * - Best-effort: no session owner, no `ctx.spillStore` backend, or a save
* failure ⇒ log and return the original result. A spill failure must NEVER
* turn a successful tool call into an `isError` or hide the inline result.
*
@@ -34,7 +34,7 @@ import z from 'schemastery'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { TextRetainer, describeOmitted } from '@deepseek-ai/dsh-retention'
import type { Omitted } from '@deepseek-ai/dsh-retention'
-import type { SaveTextSpill } from '@deepseek-ai/dsh-spill'
+import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
import type { SpillPolicyExec } from './types.ts'
@@ -86,10 +86,10 @@ function preview(text: string, budget: number): { text: string; omitted: Omitted
return { text: kept.text, omitted: kept.omittedBytes }
}
-/** The spill-notice line for a given omission + path (no preview, no leading blank line). */
-function spillNotice(omitted: Omitted, spillPath: string): string {
+/** The spill-notice line for a given omission + saved reference (no preview, no leading blank line). */
+function spillNotice(omitted: Omitted, ref: SpillRef): string {
const omission = describeOmitted(omitted, 'bytes')
- return `(${omission} Full formatted result saved to: ${spillPath}. Use read with offset/limit to inspect it.)`
+ return `(${omission} Full formatted result stored at: ${ref.locator}. ${ref.retrievalHint})`
}
export function apply(ctx: Context, config: Config): void {
@@ -108,7 +108,7 @@ export function apply(ctx: Context, config: Config): void {
// we bound whatever it accepted. A block passes through — spill only shapes
// accepted plain-text results, never corrective feedback.
const decision = await next()
- // Skip `read` to avoid a read → spill file → read again loop.
+ // Skip `read` to avoid a read → spill → read again loop.
if (decision.kind !== 'accept' || exec.name === 'read') return decision
const content = decision.content ?? result.content
@@ -122,9 +122,9 @@ export function apply(ctx: Context, config: Config): void {
ctx.logger.warn(`spill-policy: no session owner for ${exec.name} result; keeping the inline result`)
return decision
}
- const spillFiles = ctx.get('spillFiles')
- if (!spillFiles) {
- ctx.logger.warn('spill-policy: no ctx.spillFiles backend loaded; keeping the inline result')
+ const spillStore = ctx.get('spillStore')
+ if (!spillStore) {
+ ctx.logger.warn('spill-policy: no ctx.spillStore backend loaded; keeping the inline result')
return decision
}
@@ -134,9 +134,9 @@ export function apply(ctx: Context, config: Config): void {
suggestedName: `${exec.name}.txt`,
content: text,
}
- let path: string
+ let ref: SpillRef
try {
- ({ path } = await spillFiles.saveText(save))
+ 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 result — keep the original inline.
@@ -152,10 +152,10 @@ export function apply(ctx: Context, config: Config): void {
// count (the full byte total): its digit count bounds the real count's, so
// the reserved size is a safe upper bound and the final notice is never
// longer than what we reserved. `\n\n` is the 2-byte join.
- const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, path), 'utf8') + 2
+ const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2
const previewBudget = Math.max(0, maxInlineBytes - reserve)
const { text: previewText, omitted } = preview(text, previewBudget)
- const notice = spillNotice(omitted, path)
+ const notice = spillNotice(omitted, ref)
const replacedText = previewText.length > 0 ? `${previewText}\n\n${notice}` : notice
// Invariant: the policy NEVER emits a replacement larger than the cap. When
// the notice alone exceeds maxInlineBytes (a tiny cap or a long spill root),
diff --git a/packages/spill/spill-policy/src/types.ts b/packages/spill/spill-policy/src/types.ts
index 032d0af550..3046e3efe5 100644
--- a/packages/spill/spill-policy/src/types.ts
+++ b/packages/spill/spill-policy/src/types.ts
@@ -1,6 +1,6 @@
/**
* Vocabulary for the spill-policy plugin: the minimal structural view of a tool
- * execution the policy needs to derive the owning session for a spill file.
+ * execution the policy needs to derive the owning session for a spill artifact.
*
* `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies this shape, so the policy
* reads `exec` straight through without importing `dsh-tools` or `dsh-agent`.
diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts
index b0678c27c2..cd1cfb8356 100644
--- a/packages/spill/spill-policy/tests/spill-policy.spec.ts
+++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts
@@ -2,7 +2,7 @@
* Tests for the spill-policy PLUGIN. It registers no service, only the
* `tools/post-execute` transformer. We drive real tools through
* `ctx.tools.execute(...)` and assert: disabled mode is a true no-op, an
- * oversized plain-text result is spilled and replaced with a preview + path,
+ * oversized plain-text result is spilled and replaced with a preview + locator,
* a small result and a non-text result pass through, `read` is skipped, and a
* `saveText` failure / missing backend / missing owner all preserve the original
* result without an `isError`.
@@ -17,19 +17,23 @@ import { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
-import { SpillFiles, SpillPath } from '@deepseek-ai/dsh-spill'
+import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
/** A stub spill backend recording its saves; `fail` exercises the best-effort fallback. */
-class StubSpill extends SpillFiles {
+class StubStore extends SpillStore {
saves: SaveTextSpill[] = []
fail = false
async saveText(input: SaveTextSpill): Promise {
if (this.fail) throw new Error('disk full')
this.saves.push(input)
- return { path: SpillPath(`/spill/${input.suggestedName}`), bytes: Buffer.byteLength(input.content, 'utf8') }
+ return {
+ locator: SpillLocator(`/spill/${input.suggestedName}`),
+ bytes: Buffer.byteLength(input.content, 'utf8'),
+ retrievalHint: 'Use the stub retrieval path.',
+ }
}
}
@@ -54,14 +58,14 @@ function exec(name: string, session = 's1'): ToolExecution {
* Build a context with tools + the policy, and optionally a spill backend.
* Returns the context and the backend handle (undefined when `withSpill` false).
*/
-async function setup(config: SpillPolicy.Config, withSpill = true): Promise<{ ctx: Context; spill?: StubSpill; fiber: Awaited> }> {
+async function setup(config: SpillPolicy.Config, withSpill = true): Promise<{ ctx: Context; spill?: StubStore; fiber: Awaited> }> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
- let spill: StubSpill | undefined
+ let spill: StubStore | undefined
if (withSpill) {
- await ctx.plugin(StubSpill)
- spill = ctx.spillFiles as StubSpill
+ await ctx.plugin(StubStore)
+ spill = ctx.spillStore as StubStore
}
const fiber = await ctx.plugin(SpillPolicy, config)
return { ctx, fiber, ...spill ? { spill } : {} }
@@ -108,7 +112,7 @@ describe('config validation', () => {
})
describe('oversized plain-text replacement', () => {
- it('spills the full text and replaces the result with a preview + path within the cap', async () => {
+ it('spills the full text and replaces the result with a preview + locator within the cap', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 200 })
const body = 'HEAD'.repeat(200) + 'TAIL'.repeat(200) // 1600 bytes > 200
ctx.tools.register(textTool('big', body))
@@ -124,8 +128,8 @@ describe('oversized plain-text replacement', () => {
const text = textOf(result.content)
expect(text).not.toBe(body)
expect(text.startsWith('HEAD')).toBe(true)
- expect(text).toContain('Full formatted result saved to: /spill/big.txt')
- expect(text).toContain('Use read with offset/limit')
+ expect(text).toContain('Full formatted result stored at: /spill/big.txt')
+ expect(text).toContain('Use the stub retrieval path.')
expect(text).toContain('Omitted')
// The replacement (preview + blank line + notice) stays within the cap and
// is smaller than the original — the whole point of spilling.
@@ -221,7 +225,7 @@ describe('composition', () => {
ctx.tools.register(textTool('small', 'tiny'))
const result = await ctx.tools.execute(exec('small'))
expect(spill?.saves[0]?.content).toBe('z'.repeat(500))
- expect(textOf(result.content)).toContain('Full formatted result saved to')
+ expect(textOf(result.content)).toContain('Full formatted result stored at')
})
it('preserves a downstream accept decision additionalContext when spilling', async () => {
@@ -231,7 +235,7 @@ describe('composition', () => {
({ kind: 'accept', additionalContext: context }))
ctx.tools.register(textTool('big', 'x'.repeat(1000)))
const result = await ctx.tools.execute(exec('big'))
- expect(textOf(result.content)).toContain('Full formatted result saved to')
+ expect(textOf(result.content)).toContain('Full formatted result stored at')
expect(result.additionalContext).toEqual(context)
})
})
@@ -259,7 +263,7 @@ describe('disposal (HMR safety)', () => {
// Live: the listener spills and replaces.
const before = await ctx.tools.execute(exec('big'))
- expect(textOf(before.content)).toContain('Full formatted result saved to')
+ expect(textOf(before.content)).toContain('Full formatted result stored at')
expect(spill?.saves).toHaveLength(1)
// After disposal the listener is gone — the result passes through untouched
diff --git a/packages/spill/spill/README.md b/packages/spill/spill/README.md
index 50f115573b..f550e31d84 100644
--- a/packages/spill/spill/README.md
+++ b/packages/spill/spill/README.md
@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-spill
-The **spill storage seam**: an abstract `SpillFiles` service (`ctx.spillFiles`) defining WHAT a spill backend does — persist a tool's oversized text to a session-scoped path the model can later `read` — without saying HOW.
+The **spill storage seam**: an abstract `SpillStore` service (`ctx.spillStore`) defining WHAT a spill backend does — persist a tool's oversized text and return a model-facing locator plus retrieval guidance — without saying HOW.
This package is one third of the spill capability, split so each concern evolves (and swaps) independently:
@@ -10,18 +10,18 @@ This package is one third of the spill capability, split so each concern evolves
| `@deepseek-ai/dsh-spill-local` | an implementation: private session-scoped files on the host filesystem |
| `@deepseek-ai/dsh-spill-policy` | the tool-result policy that spills oversized final results |
-The split mirrors the bash/fs seams. A future remote or virtual backend (e.g. a `spill://…` URI plus a read-only bridge for ACP or remote environments) implements this interface without touching the policy plugin.
+The split mirrors the bash/fs seams. A future remote or virtual backend (e.g. a `spill://…` URI, a database key, or a backend-specific retrieval tool) implements this interface without touching the policy plugin.
-## Service API (`ctx.spillFiles`)
+## Service API (`ctx.spillStore`)
| Member | Semantics |
|---|---|
-| `saveText(input)` | Persist `input.content` verbatim to a session-scoped file; resolves with a `SpillRef` (path readable by the local `read` tool + exact bytes written). **Rejects on a real storage failure** (permissions, ENOSPC, backend unavailable) — the caller decides how to degrade. |
+| `saveText(input)` | Persist `input.content` verbatim; resolves with a `SpillRef` (opaque locator, exact bytes written, and retrieval hint). **Rejects on a real storage failure** (permissions, ENOSPC, backend unavailable) — the caller decides how to degrade. |
-Storage is scoped by the request's `owner` session; the backend chooses a private (not world-readable) location and a collision-free name derived from — never equal to — the caller's `suggestedName`. The seam owns storage only: NO retention policy (that is [`@deepseek-ai/dsh-retention`](../../util/retention)), NO tool-result replacement (that is `@deepseek-ai/dsh-spill-policy`), NO file inspection (the model uses the existing `read` tool on the returned path).
+Storage is scoped by the request's `owner` session; the backend chooses its own private representation and may derive names from — never trust as a path — the caller's `suggestedName`. The seam owns storage only: NO retention policy (that is [`@deepseek-ai/dsh-retention`](../../util/retention)), NO tool-result replacement (that is `@deepseek-ai/dsh-spill-policy`), NO retrieval/search API (the backend's `retrievalHint` tells the model what to do with the locator).
## Vocabulary
-`SaveTextSpill` (owner, source, suggestedName, content) is the request; `SpillRef` (path, bytes) is the result. `SpillPath` is [branded](../../util/brand) and rendered to the model as an ordinary path string in v1 — the brand records provenance (a runtime artifact, not a workspace file) so a future virtual backend can swap the path shape without a consumer change. `SpillOwner` scopes storage to a `SessionId`; unlike the bash executor's decoupled `OwnerToken`, spill is inherently session-scoped, so the seam imports `dsh-session`'s `SessionId` directly. `SpillSource` (toolName, callId, label) is descriptive provenance for the filename and future cleanup, not access control. See `src/types.ts` for the full contracts.
+`SaveTextSpill` (owner, source, suggestedName, content) is the request; `SpillRef` (locator, bytes, retrievalHint) is the result. `SpillLocator` is [branded](../../util/brand) and rendered to the model as an opaque string — a local path for `dsh-spill-local`, but a future backend may return a URI, key, or command token without changing policy/tool consumers. `SpillOwner` scopes storage to a `SessionId`; unlike the bash executor's decoupled `OwnerToken`, spill is inherently session-scoped, so the seam imports `dsh-session`'s `SessionId` directly. `SpillSource` (toolName, callId, label) is descriptive provenance for backend naming and future cleanup, not access control. See `src/types.ts` for the full contracts.
See the [tool output spill RFC](../../../docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design rationale, including why creation belongs to the runtime spill seam rather than the model-facing `write` tool.
diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json
index 167c67183c..3103c9cd11 100644
--- a/packages/spill/spill/package.json
+++ b/packages/spill/spill/package.json
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-spill",
- "description": "Abstract spill storage seam (ctx.spillFiles) for the DeepSeek Harness — save oversized tool text to a session-scoped path",
+ "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator",
"version": "0.0.1",
"private": true,
"type": "module",
diff --git a/packages/spill/spill/src/index.ts b/packages/spill/spill/src/index.ts
index 4c8fa37030..4c8826defb 100644
--- a/packages/spill/spill/src/index.ts
+++ b/packages/spill/spill/src/index.ts
@@ -1,16 +1,15 @@
/**
- * The spill storage seam (`ctx.spillFiles`): an abstract service defining WHAT a
- * spill backend does — persist a tool's oversized text to a session-scoped path
- * the model can later `read` — without saying HOW. Implementations subclass
- * {@link SpillFiles} and register as the `spillFiles` service;
+ * The spill storage seam (`ctx.spillStore`): an abstract service defining WHAT a
+ * spill backend does — persist a tool's oversized text and return a model-facing
+ * locator plus retrieval guidance — without saying HOW. Implementations
+ * subclass {@link SpillStore} and register as the `spillStore` service;
* `@deepseek-ai/dsh-spill-local` (host filesystem) is the first.
*
* The seam is deliberately minimal: `saveText` and nothing else. It owns NO
* retention policy (that is `@deepseek-ai/dsh-retention`), NO tool-result
- * replacement (that is `@deepseek-ai/dsh-spill-policy`), and NO file inspection
- * (the model uses the existing `read` tool on the returned path). A future
- * remote/virtual backend may return a `spill://…` URI plus a read-only bridge;
- * v1 keeps the path filesystem-shaped until such a backend exists.
+ * replacement (that is `@deepseek-ai/dsh-spill-policy`), and NO retrieval or
+ * search API. The backend supplies the locator and retrieval hint appropriate
+ * for its storage substrate.
*
* @module @deepseek-ai/dsh-spill
*/
@@ -18,24 +17,24 @@
import { Context, Service } from 'cordis'
import type { SaveTextSpill, SpillRef } from './types.ts'
-export { SpillPath } from './types.ts'
+export { SpillLocator } from './types.ts'
export type { SaveTextSpill, SpillOwner, SpillRef, SpillSource } from './types.ts'
declare module 'cordis' {
interface Context {
- spillFiles: SpillFiles
+ spillStore: SpillStore
}
}
/**
* Abstract spill storage service. Subclass, implement {@link saveText}, and load
- * the subclass as a plugin — it registers as `ctx.spillFiles` (one
+ * the subclass as a plugin — it registers as `ctx.spillStore` (one
* implementation per context; loading a second throws, cordis' standard
* duplicate-service behavior).
*
* Semantics every implementation must honor:
- * - {@link saveText} persists the FULL `content` verbatim and returns a path
- * the local `read` tool can open, plus the exact byte length written.
+ * - {@link saveText} persists the FULL `content` verbatim and returns an opaque
+ * locator, exact byte length, and model-facing retrieval guidance.
* - Storage is scoped by the request's {@link SaveTextSpill.owner} session; the
* backend chooses a private (not world-readable) location and a collision-free
* name derived from — never equal to — the caller's `suggestedName`.
@@ -43,18 +42,17 @@ declare module 'cordis' {
* unavailable); the caller decides how to degrade (the spill policy treats a
* rejection as best-effort and keeps the inline result).
*/
-export abstract class SpillFiles extends Service {
+export abstract class SpillStore extends Service {
constructor(ctx: Context) {
- super(ctx, 'spillFiles')
+ super(ctx, 'spillStore')
}
/**
- * Persist `input.content` to a session-scoped spill file.
+ * Persist `input.content` to a session-scoped spill artifact.
* @param input - the owner, provenance, suggested name, and full text to save.
- * @returns the saved file's {@link SpillRef} (path + bytes written); rejects on
- * a storage failure.
+ * @returns the saved artifact's {@link SpillRef}; rejects on a storage failure.
*/
abstract saveText(input: SaveTextSpill): Promise
}
-export default SpillFiles
+export default SpillStore
diff --git a/packages/spill/spill/src/types.ts b/packages/spill/spill/src/types.ts
index 28be96c738..5290a9738e 100644
--- a/packages/spill/spill/src/types.ts
+++ b/packages/spill/spill/src/types.ts
@@ -11,22 +11,20 @@ import type { CallId } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
/**
- * A local filesystem path produced by the spill seam, intended for the model's
- * `read` tool. The brand records that the path came from {@link SpillFiles.saveText}
- * (a runtime artifact, not a workspace file); it is still rendered to the model
- * as an ordinary path string in v1. A future remote/virtual backend may replace
- * this with a `spill://…` URI, so consumers treat it as opaque.
+ * Opaque model-facing handle for one spilled artifact. A local backend may use a
+ * filesystem path; a remote or database backend may use a URI or key. Consumers
+ * render it with {@link SpillRef.retrievalHint}, but do not parse it.
*/
-export type SpillPath = Branded<'SpillPath'>
+export type SpillLocator = Branded<'SpillLocator'>
/**
- * Brand a string as a {@link SpillPath}.
+ * Brand a string as a {@link SpillLocator}.
*
- * @param path The backend-produced path string to brand.
- * @returns The branded spill path.
+ * @param locator The backend-produced locator string to brand.
+ * @returns The branded spill locator.
*/
-export function SpillPath(path: string): SpillPath {
- return path as SpillPath
+export function SpillLocator(locator: string): SpillLocator {
+ return locator as SpillLocator
}
/**
@@ -53,7 +51,7 @@ export interface SpillSource {
label: string
}
-/** One request to persist text to a spill file. */
+/** One request to persist text to a spill artifact. */
export interface SaveTextSpill {
owner: SpillOwner
source: SpillSource
@@ -66,8 +64,9 @@ export interface SaveTextSpill {
content: string
}
-/** A saved spill file: its path plus the byte length written. */
+/** A saved spill artifact: its locator, byte length, and backend-specific retrieval guidance. */
export interface SpillRef {
- path: SpillPath
+ locator: SpillLocator
bytes: number
+ retrievalHint: string
}
diff --git a/packages/spill/spill/tests/service.spec.ts b/packages/spill/spill/tests/service.spec.ts
index 271725442b..ddbc4086e1 100644
--- a/packages/spill/spill/tests/service.spec.ts
+++ b/packages/spill/spill/tests/service.spec.ts
@@ -1,6 +1,6 @@
/**
* Tests for the spill seam INTERFACE: a minimal concrete subclass registers as
- * `ctx.spillFiles`, a second load throws (duplicate service), and disposal
+ * `ctx.spillStore`, a second load throws (duplicate service), and disposal
* releases the service. The storage behavior is the implementation's concern
* (`@deepseek-ai/dsh-spill-local`); here we only pin the seam contract.
*/
@@ -9,16 +9,20 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
-import { SpillFiles, SpillPath } from '@deepseek-ai/dsh-spill'
+import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
/** Minimal concrete backend: records the last request, returns a fixed ref. */
-class StubSpill extends SpillFiles {
+class StubStore extends SpillStore {
last: SaveTextSpill | undefined
async saveText(input: SaveTextSpill): Promise {
this.last = input
- return { path: SpillPath(`/stub/${input.suggestedName}`), bytes: Buffer.byteLength(input.content, 'utf8') }
+ return {
+ locator: SpillLocator(`/stub/${input.suggestedName}`),
+ bytes: Buffer.byteLength(input.content, 'utf8'),
+ retrievalHint: 'Use the stub reader.',
+ }
}
}
@@ -32,25 +36,25 @@ function request(content: string): SaveTextSpill {
}
describe('spill seam', () => {
- it('registers as ctx.spillFiles and saves text', async () => {
+ it('registers as ctx.spillStore and saves text', async () => {
const ctx = new Context()
- await ctx.plugin(StubSpill)
- const ref = await ctx.spillFiles.saveText(request('hello'))
- expect(ref).toEqual({ path: '/stub/web_fetch.txt', bytes: 5 })
- expect((ctx.spillFiles as StubSpill).last?.content).toBe('hello')
+ await ctx.plugin(StubStore)
+ const ref = await ctx.spillStore.saveText(request('hello'))
+ expect(ref).toEqual({ locator: '/stub/web_fetch.txt', bytes: 5, retrievalHint: 'Use the stub reader.' })
+ expect((ctx.spillStore as StubStore).last?.content).toBe('hello')
})
it('rejects a second implementation (one per context)', async () => {
const ctx = new Context()
- await ctx.plugin(StubSpill)
- await expect(ctx.plugin(StubSpill)).rejects.toThrow()
+ await ctx.plugin(StubStore)
+ await expect(ctx.plugin(StubStore)).rejects.toThrow()
})
it('releases the service on disposal', async () => {
const ctx = new Context()
- const fiber = await ctx.plugin(StubSpill)
- expect(ctx.spillFiles).toBeInstanceOf(StubSpill)
+ const fiber = await ctx.plugin(StubStore)
+ expect(ctx.spillStore).toBeInstanceOf(StubStore)
await fiber.dispose()
- expect((ctx as Context & { spillFiles?: unknown }).spillFiles).toBeUndefined()
+ expect((ctx as Context & { spillStore?: unknown }).spillStore).toBeUndefined()
})
})
diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts
index 5669eb6f84..ccebaaaae6 100644
--- a/packages/support/acp-snapshot/src/normalize.ts
+++ b/packages/support/acp-snapshot/src/normalize.ts
@@ -61,8 +61,8 @@ function scrubString(value: string, ctx: NormalizeContext): string {
// residual UUID (covers ids that appear in places we didn't enumerate).
out = out.split(ctx.cwd).join(CWD)
out = out.split(`/private${CWD}`).join(CWD)
- out = out.replace(LOCAL_SPILL_PATH_RE, (_match, name: string) => `{{spillPath:${name}}}`)
- out = out.replace(SNAPSHOT_SPILL_PATH_RE, (_match, name: string) => `{{spillPath:${name}}}`)
+ out = out.replace(LOCAL_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`)
+ out = out.replace(SNAPSHOT_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`)
for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID)
out = out.replace(UUID_RE, SESSION_ID)
return out
diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts
index b104a3ad2b..f9df56c180 100644
--- a/packages/support/acp-snapshot/tests/normalize.spec.ts
+++ b/packages/support/acp-snapshot/tests/normalize.spec.ts
@@ -98,12 +98,12 @@ describe('normalizeSessionLog', () => {
data: {
content: [{
type: 'text',
- text: `Full formatted result saved to: ${ctx.cwd}/.spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit to inspect it.`,
+ text: `Full formatted result stored at: ${ctx.cwd}/.spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`,
}],
},
})
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
- expect(out).toContain('{{spillPath:bash.txt}}')
+ expect(out).toContain('{{spillLocator:bash.txt}}')
expect(out).not.toContain('session-c22bc3f1d2af')
expect(out).not.toContain('8a7b6c5d4e3f')
})
@@ -114,13 +114,13 @@ describe('normalizeSessionLog', () => {
data: {
content: [{
type: 'text',
- text: `Full formatted result saved to: /private${ctx.cwd}/.spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit to inspect it.`,
+ text: `Full formatted result stored at: /private${ctx.cwd}/.spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`,
}],
},
})
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
- expect(out).toContain('{{spillPath:bash.txt}}')
- expect(out).not.toContain('/private{{spillPath')
+ expect(out).toContain('{{spillLocator:bash.txt}}')
+ expect(out).not.toContain('/private{{spillLocator')
})
it('scrubs fixed snapshot spill paths', () => {
@@ -129,12 +129,12 @@ describe('normalizeSessionLog', () => {
data: {
content: [{
type: 'text',
- text: 'Full formatted result saved to: /tmp/dsh-acp-snapshot-spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit to inspect it.',
+ text: 'Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.',
}],
},
})
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
- expect(out).toContain('{{spillPath:bash.txt}}')
+ expect(out).toContain('{{spillLocator:bash.txt}}')
expect(out).not.toContain('/tmp/dsh-acp-snapshot-spill')
})
diff --git a/packages/web/tool-web/tests/spill.spec.ts b/packages/web/tool-web/tests/spill.spec.ts
index e44cbb7b45..58599d2c54 100644
--- a/packages/web/tool-web/tests/spill.spec.ts
+++ b/packages/web/tool-web/tests/spill.spec.ts
@@ -1,10 +1,10 @@
/**
* Showcase integration: the real `web_fetch` tool + the real spill stack
* (`dsh-spill-local` backend + `dsh-spill-policy`), exercised through
- * `ctx.tools.execute()`. Proves the RFC's default path — a large formatted fetch
- * result is automatically retained and spilled with NO tool-specific spill code,
- * and the model-facing text changes ONLY by the deliberate spill notice (the
- * full formatted result lands in the spill file).
+ * `ctx.tools.execute()`. Proves the RFC's default local-backend path — a large
+ * formatted fetch result is automatically retained and spilled with NO
+ * tool-specific spill code, and the model-facing text changes ONLY by the
+ * deliberate spill notice (the full formatted result lands in the spill file).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -21,7 +21,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import WebService from '@deepseek-ai/dsh-web'
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
-import LocalSpillFiles from '@deepseek-ai/dsh-spill-local'
+import LocalSpillStore from '@deepseek-ai/dsh-spill-local'
import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
@@ -50,7 +50,7 @@ beforeEach(async () => {
// Provider cap generous so the tool returns a large formatted result; the
// policy cap is what triggers the spill (the RFC's separation of concerns).
await ctx.plugin(WebFetchLocal, { maxBodyChars: 500_000 })
- await ctx.plugin(LocalSpillFiles, { root: spillRoot })
+ await ctx.plugin(LocalSpillStore, { root: spillRoot })
await ctx.plugin(SpillPolicy, { maxInlineBytes: MAX_INLINE_BYTES })
await ctx.plugin(ToolWeb)
})
@@ -68,7 +68,7 @@ function fetchCall(): Promise<{ isError: boolean; content: { type: string; text?
}
describe('web_fetch spill showcase', () => {
- it('spills a large formatted result and returns a preview + spill path', async () => {
+ it('spills a large formatted result and returns a preview + spill locator', async () => {
const out = await fetchCall()
expect(out.isError).toBe(false)
const text = out.content.map(b => b.text).join('')
@@ -77,11 +77,11 @@ describe('web_fetch spill showcase', () => {
expect(text.length).toBeLessThan(BODY.length)
expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(MAX_INLINE_BYTES)
expect(text).toContain(`Fetched ${base}`) // the head of the formatted result survives
- expect(text).toContain('Full formatted result saved to:')
- expect(text).toContain('Use read with offset/limit')
+ expect(text).toContain('Full formatted result stored at:')
+ expect(text).toContain('Use read with offset/limit, or grep this path')
// The spill file holds the FULL formatted result the tool returned.
- const match = /saved to: (\S+?)\. Use read/.exec(text)
+ const match = /stored at: (\S+?)\. Use read/.exec(text)
expect(match).not.toBeNull()
const spillPath = match![1]!
const saved = readFileSync(spillPath, 'utf8')
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index a94f470299..74a8987911 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -236,13 +236,13 @@ const SERVICE_ROLES: ServiceRole[] = [
note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.',
},
{
- key: 'spillFiles',
+ key: 'spillStore',
pkg: 'spill',
title: 'Spill storage seam',
mode: 'seam',
implementations: ['spill-local'],
consumers: ['spill-policy'],
- note: 'The backend saves oversized tool text to a session-scoped path; spill-policy is the tools/post-execute consumer that decides when to spill.',
+ note: 'The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill.',
},
{
key: 'workflows',
diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts
index 922a1994e8..34da17aa56 100644
--- a/scripts/gen-tool-catalog.ts
+++ b/scripts/gen-tool-catalog.ts
@@ -193,13 +193,13 @@ const TOOL_PACKAGES: ToolPackage[] = [
async mount(ctx) {
// The tools inject `bash` (search executes fixed `rg` commands through
// the executor seam, not ctx.fs); boot the local executor to satisfy it.
- // `ctx.spillFiles` is optional (read via ctx.get) and does not affect the
+ // `ctx.spillStore` is optional (read via ctx.get) and does not affect the
// schemas, so no spill backend is mounted.
await ctx.plugin(LocalBashExecutor)
await ctx.plugin(ToolFsSearch)
},
note:
- 'glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillFiles backend; returned paths are follow-up-readable in co-located bash/filesystem deployments.',
+ 'glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.',
},
{
pkg: '@deepseek-ai/dsh-tool-skill',
diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json
index 4968a0f19f..19e144c2c5 100644
--- a/scripts/type-equiv.manifest.json
+++ b/scripts/type-equiv.manifest.json
@@ -126,7 +126,7 @@
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SpillOwner", "source": "packages/spill/spill/src/types.ts" },
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SpillSource", "source": "packages/spill/spill/src/types.ts" },
{ "doc": "docs/core-data-structures/spill.md", "symbol": "SpillRef", "source": "packages/spill/spill/src/types.ts" },
- { "doc": "docs/core-data-structures/spill.md", "symbol": "SpillPath", "source": "packages/spill/spill/src/types.ts" },
+ { "doc": "docs/core-data-structures/spill.md", "symbol": "SpillLocator", "source": "packages/spill/spill/src/types.ts" },
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowStartRequest", "source": "packages/workflow/workflow/src/types.ts" },
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" },