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
+31
View File
@@ -0,0 +1,31 @@
# @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.
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.
## Config
| Key | Default | Meaning |
|---|---|---|
| `maxInlineBytes` | *(omitted)* | Model-facing context cap for a plain-text result, in UTF-8 bytes. **Omitted disables the policy entirely** (the plugin registers nothing). When set, a larger result is spilled and replaced with a preview derived from the same budget (head/tail split). |
## 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).
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:
```text
<retained head/tail preview>
(Omitted N bytes. Full formatted result saved to: /…/session-…/…-web_fetch.txt. Use read with offset/limit to inspect it.)
```
**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.
## 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).
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@deepseek-ai/dsh-spill-policy",
"description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service surface)",
"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-llm": "^0.0.1",
"@deepseek-ai/dsh-retention": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-spill": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-retention": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-spill": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
+149
View File
@@ -0,0 +1,149 @@
/**
* 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.
*
* It registers NO service and owns NO storage or preview mechanics: preview is
* `@deepseek-ai/dsh-retention` (`TextRetainer`), storage is `ctx.spillFiles`.
* The policy only decides WHEN to spill and composes the notice.
*
* ## Deliberately narrow
*
* - Omitted `maxInlineBytes` ⇒ the plugin registers nothing (a true no-op).
* - 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
* 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.
*
* It COMPOSES with other post-execute listeners: it delegates via `next()` and
* bounds the resulting `accept` content, so a hook that replaced the content
* still has its replacement bounded, and a `block` decision passes through
* unchanged.
*
* @module @deepseek-ai/dsh-spill-policy
*/
import type { Context } from 'cordis'
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 { SessionId } from '@deepseek-ai/dsh-session'
import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
import type { SpillPolicyExec } from './types.ts'
export type { SpillPolicyExec } from './types.ts'
/** Plugin config. */
export interface Config {
/**
* The model-facing context cap for a plain-text tool result, in UTF-8 bytes.
* Omitted disables the policy entirely (no-op). When set, a result larger than
* this is spilled and replaced with a preview derived from this same budget.
*/
maxInlineBytes?: number
}
/** Cordis plugin name used by loader diagnostics. */
export const name = 'spill-policy'
/** Require the tool registry (its `tools/post-execute` waterfall is the seam we transform). */
export const inject = ['tools']
export const Config: z<Config> = z.object({
maxInlineBytes: z.number(),
})
/** All-text content flattened to one UTF-8 string, or `undefined` if any block is non-text. */
function flattenPlainText(content: ContentBlock[]): string | undefined {
let text = ''
for (const block of content) {
if (block.type !== 'text') return undefined
text += block.text
}
return text
}
/** The owning session id, or `undefined` for a call with no agent (a direct/test call). */
function ownerSessionId(exec: ToolExecution): SessionId | undefined {
return (exec as SpillPolicyExec).agent?.session.header.id
}
/** Build the bounded head/tail preview for `text`, splitting `maxInlineBytes` across the two ends. */
function preview(text: string, maxInlineBytes: number): { text: string; omitted: Omitted } {
const headBytes = Math.ceil(maxInlineBytes / 2)
const tailBytes = Math.floor(maxInlineBytes / 2)
const retainer = new TextRetainer({ kind: 'headTail', headBytes, tailBytes })
retainer.push(text)
const kept = retainer.finish()
return { text: kept.text, omitted: kept.omittedBytes }
}
/**
* Compose the replacement text: the bounded preview, a blank line, then the
* spill notice. The omission clause comes from the retention library
* (`describeOmitted`); the recovery sentence names the concrete spill path.
*/
function replacementText(previewText: string, omitted: Omitted, spillPath: string): string {
const omission = describeOmitted(omitted, 'bytes')
const notice = `(${omission} Full formatted result saved to: ${spillPath}. Use read with offset/limit to inspect it.)`
return `${previewText}\n\n${notice}`
}
export function apply(ctx: Context, config: Config): void {
const maxInlineBytes = config.maxInlineBytes
// Omitted ⇒ no automatic spill policy: register nothing at all.
if (maxInlineBytes === undefined) return
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
// Delegate first so a downstream listener (e.g. a hook) settles the result;
// 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.
if (decision.kind !== 'accept' || exec.name === 'read') return decision
const content = decision.content ?? result.content
const text = flattenPlainText(content)
if (text === undefined) return decision
if (Buffer.byteLength(text, 'utf8') <= maxInlineBytes) return decision
const sessionId = ownerSessionId(exec)
if (sessionId === undefined) {
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')
return decision
}
const save: SaveTextSpill = {
owner: { sessionId },
source: { toolName: exec.name, callId: exec.callId, label: 'result' },
suggestedName: `${exec.name}.txt`,
content: text,
}
let path: string
try {
({ path } = await spillFiles.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.
ctx.logger.warn(`spill-policy: saveText failed for ${exec.name}: ${String(error)}; keeping the inline result`)
return decision
}
const { text: previewText, omitted } = preview(text, maxInlineBytes)
const replaced: ContentBlock[] = [{ type: 'text', text: replacementText(previewText, omitted, path) }]
return { kind: 'accept', content: replaced, ...decision.additionalContext ? { additionalContext: decision.additionalContext } : {} }
})
}
+26
View File
@@ -0,0 +1,26 @@
/**
* 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.
*
* `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies this shape, so the policy
* reads `exec` straight through without importing `dsh-tools` or `dsh-agent`.
* Only the session HEADER id is read — the same identity every other subsystem
* keys off (see `dsh-tool-bash`'s owner derivation).
*
* @module @deepseek-ai/dsh-spill-policy/types
*/
import type { SessionId } from '@deepseek-ai/dsh-session'
/** Minimal structural view of a tool execution: the owning session's header id, when present. */
export interface SpillPolicyExec {
/** The agent on whose behalf the call runs, when there is one. */
agent?: {
session: {
header: {
/** The canonical session identity — the spill owner. */
id: SessionId
}
}
}
}
@@ -0,0 +1,196 @@
/**
* 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,
* 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`.
*/
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
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 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 {
saves: SaveTextSpill[] = []
fail = false
async saveText(input: SaveTextSpill): Promise<SpillRef> {
if (this.fail) throw new Error('disk full')
this.saves.push(input)
return { path: SpillPath(`/spill/${input.suggestedName}`), bytes: Buffer.byteLength(input.content, 'utf8') }
}
}
/** A tool returning `text` verbatim (name configurable so we can register `read`). */
function textTool(name: string, text: string) {
return defineTool({
name,
description: name,
parameters: {},
async execute(): Promise<ContentBlock[]> { return [{ type: 'text', text }] },
})
}
/** A minimal exec carrying a session header id (the spill owner). */
function exec(name: string, session = 's1'): ToolExecution {
// Only agent.session.header.id is read by the policy; a structural stub suffices.
const agent = { session: { header: { id: SessionId(session) } } }
return { callId: CallId(`call-${name}`), name, arguments: {}, agent } as unknown as 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 }> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
let spill: StubSpill | undefined
if (withSpill) {
await ctx.plugin(StubSpill)
spill = ctx.spillFiles as StubSpill
}
await ctx.plugin(SpillPolicy, config)
return { ctx, ...spill ? { spill } : {} }
}
/** Flatten a result's text blocks. */
function textOf(content: ContentBlock[]): string {
return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')
}
describe('disabled mode', () => {
it('registers no post-execute listener when maxInlineBytes is omitted', async () => {
const { ctx, spill } = await setup({})
ctx.tools.register(textTool('big', 'x'.repeat(1000)))
const result = await ctx.tools.execute(exec('big'))
expect(textOf(result.content)).toBe('x'.repeat(1000))
expect(result.isError).toBe(false)
expect(spill?.saves).toHaveLength(0)
})
})
describe('oversized plain-text replacement', () => {
it('spills the full text and replaces the result with a preview + path', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 20 })
const body = 'HEAD'.repeat(20) + 'TAIL'.repeat(20) // 160 bytes > 20
ctx.tools.register(textTool('big', body))
const result = await ctx.tools.execute(exec('big'))
expect(result.isError).toBe(false)
expect(spill?.saves).toHaveLength(1)
expect(spill?.saves[0]?.content).toBe(body)
expect(spill?.saves[0]?.source.toolName).toBe('big')
expect(spill?.saves[0]?.suggestedName).toBe('big.txt')
expect(spill?.saves[0]?.owner.sessionId).toBe('s1')
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('Omitted')
})
it('leaves a small plain-text result unchanged', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 1000 })
ctx.tools.register(textTool('small', 'tiny'))
const result = await ctx.tools.execute(exec('small'))
expect(textOf(result.content)).toBe('tiny')
expect(spill?.saves).toHaveLength(0)
})
it('leaves a result with a non-text block unchanged', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 5 })
ctx.tools.register(defineTool({
name: 'mixed',
description: 'mixed',
parameters: {},
async execute(): Promise<ContentBlock[]> {
return [{ type: 'text', text: 'x'.repeat(100) }, { type: 'reasoning', text: 'why' }]
},
}))
const result = await ctx.tools.execute(exec('mixed'))
expect(spill?.saves).toHaveLength(0)
expect(result.content).toHaveLength(2)
})
})
describe('read skip', () => {
it('never spills the read tool result (avoids a read → spill → read loop)', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
ctx.tools.register(textTool('read', 'x'.repeat(1000)))
const result = await ctx.tools.execute(exec('read'))
expect(textOf(result.content)).toBe('x'.repeat(1000))
expect(spill?.saves).toHaveLength(0)
})
})
describe('best-effort fallback', () => {
it('keeps the original result when saveText fails', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
spill!.fail = true
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
ctx.tools.register(textTool('big', 'x'.repeat(1000)))
const result = await ctx.tools.execute(exec('big'))
expect(textOf(result.content)).toBe('x'.repeat(1000))
expect(result.isError).toBe(false)
expect(warn).toHaveBeenCalled()
})
it('keeps the original result when no spill backend is loaded', async () => {
const { ctx } = await setup({ maxInlineBytes: 10 }, false)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
ctx.tools.register(textTool('big', 'x'.repeat(1000)))
const result = await ctx.tools.execute(exec('big'))
expect(textOf(result.content)).toBe('x'.repeat(1000))
expect(warn).toHaveBeenCalled()
})
it('keeps the original result when the call has no session owner', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
ctx.tools.register(textTool('big', 'x'.repeat(1000)))
const result = await ctx.tools.execute({ callId: CallId('c'), name: 'big', arguments: {} })
expect(textOf(result.content)).toBe('x'.repeat(1000))
expect(spill?.saves).toHaveLength(0)
expect(warn).toHaveBeenCalled()
})
})
describe('composition', () => {
it('bounds content a downstream post-execute listener replaced', async () => {
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
// A later-registered listener replaces the (small) tool result with a big one;
// the policy delegated via next(), so it bounds the replacement.
ctx.on('tools/post-execute', async (_e, _r, _next) =>
({ kind: 'accept', content: [{ type: 'text', text: 'z'.repeat(500) }] }))
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')
})
it('preserves a downstream accept decision additionalContext when spilling', async () => {
const { ctx } = await setup({ maxInlineBytes: 10 })
const context = { content: [{ type: 'text' as const, text: 'note' }], source: { kind: 'plugin' as const, plugin: 'test' } }
ctx.on('tools/post-execute', async (_e, _r, _next) =>
({ 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(result.additionalContext).toEqual(context)
})
})
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../util/retention" },
{ "path": "../../llm/llm" },
{ "path": "../../core/session" },
{ "path": "../spill" },
{ "path": "../../core/tools" }
]
}