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:
27
packages/spill/spill/README.md
Normal file
27
packages/spill/spill/README.md
Normal 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.
|
||||
36
packages/spill/spill/package.json
Normal file
36
packages/spill/spill/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
60
packages/spill/spill/src/index.ts
Normal file
60
packages/spill/spill/src/index.ts
Normal 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
|
||||
68
packages/spill/spill/src/types.ts
Normal file
68
packages/spill/spill/src/types.ts
Normal 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
|
||||
}
|
||||
56
packages/spill/spill/tests/service.spec.ts
Normal file
56
packages/spill/spill/tests/service.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
15
packages/spill/spill/tsconfig.json
Normal file
15
packages/spill/spill/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user