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,93 @@
/**
* 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).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import { AddressInfo } from 'node:net'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
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 * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
type Handler = (req: IncomingMessage, res: ServerResponse) => void
let server: Server
let base: string
let handler: Handler
let spillRoot: string
let ctx: Context
const BODY = 'X'.repeat(4000) // formatted result is well over the 200-byte policy cap
beforeEach(async () => {
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end(BODY) }
server = createServer((req, res) => { handler(req, res) })
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
spillRoot = mkdtempSync(join(tmpdir(), 'dsh-spill-web-'))
ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(WebService, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
// 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(SpillPolicy, { maxInlineBytes: 200 })
await ctx.plugin(ToolWeb)
})
afterEach(async () => {
await new Promise<void>(resolve => server.close(() => { resolve() }))
rmSync(spillRoot, { recursive: true, force: true })
})
/** A web_fetch call carrying a session owner (so the policy can scope the spill). */
function fetchCall(): Promise<{ isError: boolean; content: { type: string; text?: string }[] }> {
const agent = { session: { header: { id: SessionId('web-sess') } } }
const exec = { callId: CallId('call-1'), name: 'web_fetch', arguments: { url: base }, agent } as unknown as ToolExecution
return ctx.tools.execute(exec)
}
describe('web_fetch spill showcase', () => {
it('spills a large formatted result and returns a preview + spill path', async () => {
const out = await fetchCall()
expect(out.isError).toBe(false)
const text = out.content.map(b => b.text).join('')
// Model-facing text is a preview + notice, NOT the full body.
expect(text.length).toBeLessThan(BODY.length)
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')
// The spill file holds the FULL formatted result the tool returned.
const match = /saved to: (\S+?)\. Use read/.exec(text)
expect(match).not.toBeNull()
const spillPath = match![1]!
const saved = readFileSync(spillPath, 'utf8')
// The provider cap was generous, so the tool did not truncate: the spill file
// holds the full formatted result (header + the complete body), far larger
// than the model-facing preview.
expect(saved).toContain('(HTTP 200)')
expect(saved).toContain(BODY)
expect(saved.length).toBeGreaterThan(text.length)
})
})