feat(spill): add tool-output spill seam, local backend, and policy

Oversized plain-text tool results now spill to a session-scoped file and
return a bounded preview plus the spill path, so a verbose result stays
readable via `read` without consuming the next model request in full.

- dsh-spill: minimal SpillFiles seam (saveText → session-scoped SpillPath)
- dsh-spill-local: private 0700 session dirs, traversal-safe names, exclusive
  owner-only writes
- dsh-spill-policy: tools/post-execute transformer; no-op unless maxInlineBytes
  is set; skips read; best-effort on save failure (never turns a success into
  an isError)

web_fetch is the showcase — no tool-specific spill code. The coding-agent
example loads the stack so its keyless Loader smoke guards the namespace-plugin
export shape. Snapshot gap for a transcript-visible web_fetch spill is recorded
in the RFC's Consequences (ACP replay is keyless and cannot hit the web).
This commit is contained in:
Dudu-0223
2026-07-08 19:20:50 +08:00
parent 4f2f34c6fd
commit 463b72ce96
36 changed files with 1549 additions and 1 deletions

View File

@@ -0,0 +1,27 @@
# @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.
This package is one third of the spill capability, split so each concern evolves (and swaps) independently:
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-spill` (this) | the interface: abstract service + vocabulary types |
| `@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.
## Service API (`ctx.spillFiles`)
| 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. |
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).
## 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.
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.

View File

@@ -0,0 +1,36 @@
{
"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",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,60 @@
/**
* 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;
* `@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.
*
* @module @deepseek-ai/dsh-spill
*/
import { Context, Service } from 'cordis'
import type { SaveTextSpill, SpillRef } from './types.ts'
export { SpillPath } from './types.ts'
export type { SaveTextSpill, SpillOwner, SpillRef, SpillSource } from './types.ts'
declare module 'cordis' {
interface Context {
spillFiles: SpillFiles
}
}
/**
* Abstract spill storage service. Subclass, implement {@link saveText}, and load
* the subclass as a plugin — it registers as `ctx.spillFiles` (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.
* - 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`.
* - `saveText` REJECTS on a real storage failure (permissions, ENOSPC, backend
* 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 {
constructor(ctx: Context) {
super(ctx, 'spillFiles')
}
/**
* Persist `input.content` to a session-scoped spill file.
* @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.
*/
abstract saveText(input: SaveTextSpill): Promise<SpillRef>
}
export default SpillFiles

View File

@@ -0,0 +1,68 @@
/**
* Vocabulary for the spill storage seam. Types only — the abstract service
* lives in `./index.ts`, implementations in sibling packages
* (`@deepseek-ai/dsh-spill-local` first).
*
* @module @deepseek-ai/dsh-spill/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
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.
*/
export type SpillPath = Branded<'SpillPath'>
/** Brand a string as a {@link SpillPath}. */
export function SpillPath(path: string): SpillPath {
return path as SpillPath
}
/**
* Who a spilled file belongs to: the session whose tool call produced it. The
* backend scopes storage per session (its directory layout, its cleanup unit),
* so the owner is the session id, not a decoupled token — spill is inherently
* session-scoped, unlike the bash executor's cross-session `OwnerToken`.
*/
export interface SpillOwner {
sessionId: SessionId
}
/**
* Provenance of one spilled artifact — recorded by the backend for a readable
* filename and future cleanup/inspection. Not interpreted for access control
* (the {@link SpillOwner} scopes storage); purely descriptive.
*/
export interface SpillSource {
/** The tool whose result was spilled (e.g. `web_fetch`). */
toolName: string
/** The model-issued call id the result belongs to. */
callId: CallId
/** A short human label for the artifact (e.g. `result`). */
label: string
}
/** One request to persist text to a spill file. */
export interface SaveTextSpill {
owner: SpillOwner
source: SpillSource
/**
* A caller-suggested base name (e.g. `web_fetch.txt`). The backend sanitizes
* it to a single safe path segment before use — it is a hint, never a path.
*/
suggestedName: string
/** The full text to persist (UTF-8). */
content: string
}
/** A saved spill file: its path plus the byte length written. */
export interface SpillRef {
path: SpillPath
bytes: number
}

View File

@@ -0,0 +1,56 @@
/**
* Tests for the spill seam INTERFACE: a minimal concrete subclass registers as
* `ctx.spillFiles`, 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.
*/
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 type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
/** Minimal concrete backend: records the last request, returns a fixed ref. */
class StubSpill extends SpillFiles {
last: SaveTextSpill | undefined
async saveText(input: SaveTextSpill): Promise<SpillRef> {
this.last = input
return { path: SpillPath(`/stub/${input.suggestedName}`), bytes: Buffer.byteLength(input.content, 'utf8') }
}
}
function request(content: string): SaveTextSpill {
return {
owner: { sessionId: SessionId('s1') },
source: { toolName: 'web_fetch', callId: CallId('c1'), label: 'result' },
suggestedName: 'web_fetch.txt',
content,
}
}
describe('spill seam', () => {
it('registers as ctx.spillFiles 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')
})
it('rejects a second implementation (one per context)', async () => {
const ctx = new Context()
await ctx.plugin(StubSpill)
await expect(ctx.plugin(StubSpill)).rejects.toThrow()
})
it('releases the service on disposal', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(StubSpill)
expect(ctx.spillFiles).toBeInstanceOf(StubSpill)
await fiber.dispose()
expect((ctx as Context & { spillFiles?: unknown }).spillFiles).toBeUndefined()
})
})

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../util/brand" },
{ "path": "../../llm/llm" },
{ "path": "../../core/session" }
]
}