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,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()
})
})