fix(review): generalize spill storage locators
This commit is contained in:
@@ -173,7 +173,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'spillFiles',
|
||||
key: 'spillStore',
|
||||
summary: 'Abstract spill storage service.',
|
||||
methods: [
|
||||
'abstract saveText(input: SaveTextSpill): Promise<SpillRef>',
|
||||
@@ -818,17 +818,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SkillSummary',
|
||||
declaration: 'export interface SkillSummary {\n name: string;\n description: string;\n whenToUse?: string;\n disableModelInvocation?: boolean;\n source: SkillSource;\n provider: string;\n resourceBase?: SkillResourceBase;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SpillLocator',
|
||||
declaration: 'export type SpillLocator = Branded<\'SpillLocator\'>;',
|
||||
},
|
||||
{
|
||||
name: 'SpillOwner',
|
||||
declaration: 'export interface SpillOwner {\n sessionId: SessionId;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SpillPath',
|
||||
declaration: 'export type SpillPath = Branded<\'SpillPath\'>;',
|
||||
},
|
||||
{
|
||||
name: 'SpillRef',
|
||||
declaration: 'export interface SpillRef {\n path: SpillPath;\n bytes: number;\n}',
|
||||
declaration: 'export interface SpillRef {\n locator: SpillLocator;\n bytes: number;\n retrievalHint: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SpillSource',
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# @deepseek-ai/dsh-tool-fs-search
|
||||
|
||||
The **model-facing filesystem discovery tools** — `glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillFiles` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
|
||||
The **model-facing filesystem discovery tools** — `glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
|
||||
|
||||
```ts ignore-check
|
||||
// Default deployment: a bash executor, then the discovery tools.
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
|
||||
await ctx.plugin(ToolFsSearch) // this package — registers glob/grep
|
||||
// Optional: a spill backend makes capped results fully recoverable.
|
||||
await ctx.plugin(LocalSpillFiles) // @deepseek-ai/dsh-spill-local
|
||||
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
|
||||
```
|
||||
|
||||
Why bash-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution (local, sandboxed, remote); this package owns schemas, argument validation, shell quoting, parsing, retention, formatted-result spill, and timeout declaration. The tools never call `ctx.bash.start()` and never expose a bash task id — the call returns only after `rg` exits, times out, is aborted, or fails.
|
||||
@@ -22,8 +22,8 @@ All keys are optional; the defaults are the shipped search caps.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `globMaxResults` | `100` | Max paths one `glob` call retains inline (matches Claude Code's `GlobTool` limit); later paths go to the formatted spill file. |
|
||||
| `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill file. |
|
||||
| `globMaxResults` | `100` | Max paths one `glob` call retains inline (matches Claude Code's `GlobTool` limit); later paths go to the formatted spill artifact. |
|
||||
| `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. |
|
||||
| `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. |
|
||||
| `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. |
|
||||
| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the bash backend's own timeout stays a second safety cap. |
|
||||
@@ -35,11 +35,11 @@ All keys are optional; the defaults are the shipped search caps.
|
||||
| `glob` | `pattern`, `path?` | `rg --files --glob <pattern> --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one path per line, modification-time ordered. |
|
||||
| `grep` | `pattern`, `path?`, `include?` | Line-oriented `rg --json` parse (no colon-splitting ambiguity). `pattern` is a ripgrep regex; `path` is an optional **file or directory** target; `include` is ONE positive glob filter — a comma-separated list or a negated (`!…`) value is rejected up front (brace alternation like `*.{ts,tsx}` is fine). Returns matches grouped by file as `Line N: <preview>`. |
|
||||
|
||||
Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results reads the formatted spill file with `read offset/limit`.
|
||||
Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results follows the returned spill locator's retrieval hint.
|
||||
|
||||
## Two budgets, two artifacts
|
||||
|
||||
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. The model-facing recovery artifact is different: when a search yields more logical results than the inline cap, the tool saves the COMPLETE formatted result through `ctx.spillFiles.saveText()` (suggested names `glob-results.txt` / `grep-results.txt`, owner = the calling session, source = the tool execution identity) and appends a footer naming the saved path. This is the first tool-owned spill call in the codebase — deliberate, because retention here is item-level: the generic `@deepseek-ai/dsh-spill-policy` only sees the final text on `tools/post-execute`, by which point a capped search has already omitted later paths/matches. A missing spill backend, a call with no session owner, or a `saveText()` failure keeps the inline page and reports that the complete result could not be saved — never an `isError`.
|
||||
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. The model-facing recovery artifact is different: when a search yields more logical results than the inline cap, the tool saves the COMPLETE formatted result through `ctx.spillStore.saveText()` (suggested names `glob-results.txt` / `grep-results.txt`, owner = the calling session, source = the tool execution identity) and appends a footer naming the returned locator and retrieval hint. This is the first tool-owned spill call in the codebase — deliberate, because retention here is item-level: the generic `@deepseek-ai/dsh-spill-policy` only sees the final text on `tools/post-execute`, by which point a capped search has already omitted later paths/matches. A missing spill backend, a call with no session owner, or a `saveText()` failure keeps the inline page and reports that the complete result could not be saved — never an `isError`.
|
||||
|
||||
## Errors
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { ItemRetainer } from '@deepseek-ai/dsh-retention'
|
||||
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
|
||||
import type { SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type {} from '@deepseek-ai/dsh-bash'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
@@ -100,18 +101,18 @@ export function buildGlobCommand(input: GlobInput): string {
|
||||
/**
|
||||
* Format the model-facing `glob` result: the retained paths, then — when the
|
||||
* result was capped — a footer carrying either the formatted-spill recovery
|
||||
* path or the could-not-save explanation. The omitted count is a budget fact:
|
||||
* locator or the could-not-save explanation. The omitted count is a budget fact:
|
||||
* the search itself completed.
|
||||
*
|
||||
* @param retained - the retention outcome over every discovered path.
|
||||
* @param spillPath - the saved complete-result path, or `undefined` when unsaved.
|
||||
* @param spillRef - the saved complete-result reference, or `undefined` when unsaved.
|
||||
* @returns the model-facing text.
|
||||
*/
|
||||
export function formatGlobOutput(retained: RetainedItems<string>, spillPath: string | undefined): string {
|
||||
export function formatGlobOutput(retained: RetainedItems<string>, spillRef: SpillRef | undefined): string {
|
||||
const body = retained.items.join('\n')
|
||||
if (!retained.truncated) return body
|
||||
const recovery = spillPath !== undefined
|
||||
? `Full sorted result saved to: ${spillPath}. Use read with offset/limit to inspect it.`
|
||||
const recovery = spillRef !== undefined
|
||||
? `Full sorted result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`
|
||||
: 'The complete result could not be saved; narrow pattern or path to see more.'
|
||||
return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})`
|
||||
}
|
||||
@@ -168,10 +169,10 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
|
||||
|
||||
// The complete sorted list is the recovery artifact; save it only when
|
||||
// the inline page omitted paths (an uncapped result needs no spill file).
|
||||
const spillPath = retained.truncated
|
||||
const spillRef = retained.truncated
|
||||
? await trySaveFormattedResult(ctx, exec, 'glob-results.txt', all.join('\n'))
|
||||
: undefined
|
||||
return [{ type: 'text', text: formatGlobOutput(retained, spillPath) }]
|
||||
return [{ type: 'text', text: formatGlobOutput(retained, spillRef) }]
|
||||
},
|
||||
presentCall: presentGlobCall,
|
||||
}))
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
|
||||
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
|
||||
import type { SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type {} from '@deepseek-ai/dsh-bash'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
@@ -221,21 +222,21 @@ export function formatGrepMatches(matches: GrepMatch[]): string {
|
||||
/**
|
||||
* Format the model-facing `grep` result: a found-count header, the retained
|
||||
* matches grouped by file, then — when the result was capped — a footer
|
||||
* carrying either the formatted-spill recovery path or the could-not-save
|
||||
* carrying either the formatted-spill recovery locator or the could-not-save
|
||||
* explanation. The omitted count is a budget fact: the search itself completed.
|
||||
*
|
||||
* @param retained - the retention outcome over every parsed match.
|
||||
* @param spillPath - the saved complete-result path, or `undefined` when unsaved.
|
||||
* @param spillRef - the saved complete-result reference, or `undefined` when unsaved.
|
||||
* @returns the model-facing text.
|
||||
*/
|
||||
export function formatGrepOutput(retained: RetainedItems<GrepMatch>, spillPath: string | undefined): string {
|
||||
export function formatGrepOutput(retained: RetainedItems<GrepMatch>, spillRef: SpillRef | undefined): string {
|
||||
const header = retained.truncated
|
||||
? `Found ${retained.kept} of ${retained.seen} matches`
|
||||
: `Found ${retained.seen} ${matchNoun(retained.seen)}`
|
||||
const body = formatGrepMatches(retained.items)
|
||||
if (!retained.truncated) return `${header}\n\n${body}`
|
||||
const recovery = spillPath !== undefined
|
||||
? `Full grep result saved to: ${spillPath}. Use read with offset/limit to inspect it.`
|
||||
const recovery = spillRef !== undefined
|
||||
? `Full grep result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`
|
||||
: 'The complete result could not be saved; narrow pattern, path, or include to see more.'
|
||||
return `${header}\n\n${body}\n\n(${recovery})`
|
||||
}
|
||||
@@ -299,7 +300,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
|
||||
// The spill file stores the FULL formatted match list (same grouped,
|
||||
// per-line-previewed shape the model saw), so read offset/limit pages the
|
||||
// same logical result; save only when the inline page omitted matches.
|
||||
const spillPath = retained.truncated
|
||||
const spillRef = retained.truncated
|
||||
? await trySaveFormattedResult(
|
||||
ctx,
|
||||
exec,
|
||||
@@ -307,7 +308,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
|
||||
`Found ${all.length} ${matchNoun(all.length)}\n\n${formatGrepMatches(all)}`,
|
||||
)
|
||||
: undefined
|
||||
return [{ type: 'text', text: formatGrepOutput(retained, spillPath) }]
|
||||
return [{ type: 'text', text: formatGrepOutput(retained, spillRef) }]
|
||||
},
|
||||
presentCall: presentGrepCall,
|
||||
}))
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* bash executor owns request defaulting/capping, subprocess execution,
|
||||
* process-group termination, environment scrubbing, raw output capture, and
|
||||
* backend substitution. The package injects `tools`, `systemPrompt`, and
|
||||
* `bash` — deliberately NOT `fs`, and `ctx.spillFiles` is read opportunistically
|
||||
* `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read opportunistically
|
||||
* with `ctx.get()` because formatted-result spill is optional.
|
||||
*
|
||||
* Returned paths are displayed relative to the resolved bash workdir and are
|
||||
@@ -52,7 +52,7 @@ export { singleQuote } from './shell-quote.ts'
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'tool-fs-search'
|
||||
|
||||
/** Services required by the search tool suite (`spillFiles` is optional, read via `ctx.get()`). */
|
||||
/** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */
|
||||
export const inject = ['tools', 'systemPrompt', 'bash']
|
||||
|
||||
/** Plugin config (all optional — `Config` supplies the defaults). */
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* detail: the tools request a per-run stdout capture budget from the bash seam,
|
||||
* parse only complete in-memory stdout within `rawOutputMaxBytes`, and never
|
||||
* read executor spill files. The model-facing recovery artifact is the
|
||||
* formatted result saved through `ctx.spillFiles.saveText()`
|
||||
* formatted result saved through `ctx.spillStore.saveText()`
|
||||
* ({@link trySaveFormattedResult}).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/search-core
|
||||
@@ -20,7 +20,7 @@ import { isAbsolute, relative, sep } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SaveTextSpill } from '@deepseek-ai/dsh-spill'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/**
|
||||
@@ -214,8 +214,8 @@ export function toWorkdirRelative(path: string, workdir: string): string {
|
||||
|
||||
/**
|
||||
* Best-effort save of one COMPLETE formatted search result through
|
||||
* `ctx.spillFiles.saveText()` — the model-facing recovery path for a capped
|
||||
* result. `spillFiles` is read with `ctx.get()` (not static inject) because
|
||||
* `ctx.spillStore.saveText()` — the model-facing recovery path for a capped
|
||||
* result. `spillStore` is read with `ctx.get()` (not static inject) because
|
||||
* formatted-result spill is optional; the spill owner is the calling agent's
|
||||
* session header id and the source is the tool execution identity. A missing
|
||||
* backend, a call with no session owner, or a `saveText()` rejection logs a
|
||||
@@ -223,26 +223,26 @@ export function toWorkdirRelative(path: string, workdir: string): string {
|
||||
* reports that the complete result could not be saved; search success never
|
||||
* turns into `isError` because spill storage is unavailable.
|
||||
*
|
||||
* @param ctx - the plugin context; `spillFiles` is looked up opportunistically.
|
||||
* @param ctx - the plugin context; `spillStore` is looked up opportunistically.
|
||||
* @param exec - the tool-execution context; supplies the owning session, tool name, and call id.
|
||||
* @param suggestedName - the backend-sanitized filename hint (e.g. `grep-results.txt`).
|
||||
* @param content - the complete formatted result to persist.
|
||||
* @returns the saved spill path, or `undefined` when the result could not be saved.
|
||||
* @returns the saved spill reference, or `undefined` when the result could not be saved.
|
||||
*/
|
||||
export async function trySaveFormattedResult(
|
||||
ctx: Context,
|
||||
exec: ToolExecution,
|
||||
suggestedName: string,
|
||||
content: string,
|
||||
): Promise<string | undefined> {
|
||||
): Promise<SpillRef | undefined> {
|
||||
const sessionId = exec.agent?.session.header.id
|
||||
if (sessionId === undefined) {
|
||||
ctx.logger.warn(`tool-fs-search: no session owner for ${exec.name} result; complete result not saved`)
|
||||
return undefined
|
||||
}
|
||||
const spillFiles = ctx.get('spillFiles')
|
||||
if (!spillFiles) {
|
||||
ctx.logger.warn(`tool-fs-search: no ctx.spillFiles backend loaded; complete ${exec.name} result not saved`)
|
||||
const spillStore = ctx.get('spillStore')
|
||||
if (!spillStore) {
|
||||
ctx.logger.warn(`tool-fs-search: no ctx.spillStore backend loaded; complete ${exec.name} result not saved`)
|
||||
return undefined
|
||||
}
|
||||
const save: SaveTextSpill = {
|
||||
@@ -252,8 +252,7 @@ export async function trySaveFormattedResult(
|
||||
content,
|
||||
}
|
||||
try {
|
||||
const { path } = await spillFiles.saveText(save)
|
||||
return path
|
||||
return await spillStore.saveText(save)
|
||||
} catch (error: unknown) {
|
||||
// Best-effort: a storage failure must never fail the search or hide the
|
||||
// inline result — the footer reports the unsaved remainder instead.
|
||||
|
||||
@@ -17,7 +17,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import { SpillFiles, SpillPath } from '@deepseek-ai/dsh-spill'
|
||||
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
import {
|
||||
@@ -95,14 +95,18 @@ class FakeBash extends BashExecutor {
|
||||
}
|
||||
|
||||
/** A recording spill backend; arm `failWith` to script a storage failure. */
|
||||
class FakeSpill extends SpillFiles {
|
||||
class FakeSpill extends SpillStore {
|
||||
saves: SaveTextSpill[] = []
|
||||
failWith?: Error
|
||||
|
||||
override saveText(input: SaveTextSpill): Promise<SpillRef> {
|
||||
if (this.failWith) return Promise.reject(this.failWith)
|
||||
this.saves.push(input)
|
||||
return Promise.resolve({ path: SpillPath(`/spill/${input.suggestedName}`), bytes: Buffer.byteLength(input.content, 'utf8') })
|
||||
return Promise.resolve({
|
||||
locator: SpillLocator(`/spill/${input.suggestedName}`),
|
||||
bytes: Buffer.byteLength(input.content, 'utf8'),
|
||||
retrievalHint: 'Use the fake retrieval hint.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +123,7 @@ async function setup(options: SetupOptions = {}) {
|
||||
if (options.spill === true) await ctx.plugin(FakeSpill)
|
||||
const fiber = await ctx.plugin(ToolFsSearch, options.config)
|
||||
const bash = ctx.bash as FakeBash
|
||||
const spill = options.spill === true ? ctx.get('spillFiles') as FakeSpill : undefined
|
||||
const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined
|
||||
return { ctx, bash, spill, fiber }
|
||||
}
|
||||
|
||||
@@ -445,12 +449,12 @@ describe('glob results', () => {
|
||||
expect(bash.specs[0]?.command).toContain("-- 'sub'")
|
||||
})
|
||||
|
||||
it('caps at globMaxResults and saves the FULL sorted list through spillFiles', async () => {
|
||||
it('caps at globMaxResults and saves the FULL sorted list through spillStore', async () => {
|
||||
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 2 }, spill: true })
|
||||
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\nd.ts\n')
|
||||
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result saved to: /spill/glob-results.txt. Use read with offset/limit to inspect it.)')
|
||||
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result stored at: /spill/glob-results.txt. Use the fake retrieval hint.)')
|
||||
expect(spill?.saves).toHaveLength(1)
|
||||
expect(spill?.saves[0]).toMatchObject({
|
||||
owner: { sessionId: 'session-1' },
|
||||
@@ -543,7 +547,7 @@ describe('grep results', () => {
|
||||
'',
|
||||
].join('\n'))
|
||||
const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') })
|
||||
expect(text(result)).toBe('Found 2 of 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\n(Full grep result saved to: /spill/grep-results.txt. Use read with offset/limit to inspect it.)')
|
||||
expect(text(result)).toBe('Found 2 of 3 matches\n\na.ts\nLine 1: one\nLine 2: two\n\n(Full grep result stored at: /spill/grep-results.txt. Use the fake retrieval hint.)')
|
||||
expect(spill?.saves[0]).toMatchObject({
|
||||
source: { toolName: 'grep', label: 'result' },
|
||||
suggestedName: 'grep-results.txt',
|
||||
|
||||
@@ -4,9 +4,9 @@ The tool-output spill capability seam: an abstract storage interface, a local fi
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `spill/` | Abstract spill storage seam (`saveText` — persist oversized tool text to a session-scoped path) | `ctx.spillFiles` |
|
||||
| `spill-local/` | Local-filesystem backend: private, session-scoped files with traversal-safe names | (registers on `ctx.spillFiles`) |
|
||||
| `spill-policy/` | `tools/post-execute` policy: replaces oversized plain-text results with a preview + spill path | (no service surface) |
|
||||
| `spill/` | Abstract spill storage seam (`saveText` — persist oversized tool text and return a locator + retrieval hint) | `ctx.spillStore` |
|
||||
| `spill-local/` | Local-filesystem backend: private, session-scoped files with traversal-safe names | (registers on `ctx.spillStore`) |
|
||||
| `spill-policy/` | `tools/post-execute` policy: replaces oversized plain-text results with a preview + spill locator | (no service surface) |
|
||||
|
||||
The interface lives at `spill/spill/`. The split mirrors bash/fs: the seam owns storage only, `spill-local` owns the filesystem mechanics, and `spill-policy` owns WHEN to spill and the model-facing notice. Preview mechanics stay in [`util/retention`](../util/README.md) — the policy composes the two without either owning the other's job.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-spill-local
|
||||
|
||||
The **local-filesystem** implementation of the [`@deepseek-ai/dsh-spill`](../spill) storage seam. Registers as `ctx.spillFiles` and persists a tool's oversized text to a private, session-scoped file the model's `read` tool can open.
|
||||
The **local-filesystem** implementation of the [`@deepseek-ai/dsh-spill`](../spill) storage seam. Registers as `ctx.spillStore` and persists a tool's oversized text to a private, session-scoped file; its locator is the file path and its retrieval hint tells the model to use `read` or `grep` on that path.
|
||||
|
||||
## Storage layout
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* `LocalSpillFiles`: the host-filesystem implementation of the
|
||||
* `LocalSpillStore`: the host-filesystem implementation of the
|
||||
* `@deepseek-ai/dsh-spill` storage seam. Persists a tool's oversized text to a
|
||||
* private, session-scoped file (see `./store.ts` for the traversal-safe naming
|
||||
* and exclusive owner-only write) and returns a path the local `read` tool can
|
||||
* open.
|
||||
* and exclusive owner-only write) and returns a path locator plus local
|
||||
* read/grep retrieval guidance.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-spill-local
|
||||
*/
|
||||
@@ -11,7 +11,7 @@
|
||||
import { Context } from 'cordis'
|
||||
import { resolve } from 'node:path'
|
||||
import z from 'schemastery'
|
||||
import { SpillFiles, SpillPath } from '@deepseek-ai/dsh-spill'
|
||||
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import { privateRoot, saveTextFile } from './store.ts'
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface Config {
|
||||
* (0700) root — a spilled tool result must not be readable by other local users
|
||||
* or redirectable via a planted symlink.
|
||||
*/
|
||||
export class LocalSpillFiles extends SpillFiles {
|
||||
export class LocalSpillStore extends SpillStore {
|
||||
static Config: z<Config> = z.object({
|
||||
root: z.string(),
|
||||
})
|
||||
@@ -54,8 +54,12 @@ export class LocalSpillFiles extends SpillFiles {
|
||||
suggestedName: input.suggestedName,
|
||||
content: input.content,
|
||||
})
|
||||
return { path: SpillPath(saved.path), bytes: saved.bytes }
|
||||
return {
|
||||
locator: SpillLocator(saved.path),
|
||||
bytes: saved.bytes,
|
||||
retrievalHint: 'Use read with offset/limit, or grep this path to search within it.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalSpillFiles
|
||||
export default LocalSpillStore
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Tests for the LOCAL spill backend: `saveText` writes a session-scoped file and
|
||||
* returns its path + byte length, filename sanitization neutralizes traversal,
|
||||
* the configured `root` is honored (and the private default when omitted), and a
|
||||
* storage failure rejects. The Cordis-free `store.ts` helpers are exercised
|
||||
* directly for the naming/encoding edge cases.
|
||||
* returns a locator + byte length + retrieval hint, filename sanitization
|
||||
* neutralizes traversal, the configured `root` is honored (and the private
|
||||
* default when omitted), and a storage failure rejects. The Cordis-free
|
||||
* `store.ts` helpers are exercised directly for the naming/encoding edge cases.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
|
||||
@@ -14,7 +14,7 @@ import { dirname, isAbsolute, join } from 'node:path'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SaveTextSpill } from '@deepseek-ai/dsh-spill'
|
||||
import LocalSpillFiles, { encodeSegment, privateRoot, saveTextFile, sessionDir } from '@deepseek-ai/dsh-spill-local'
|
||||
import LocalSpillStore, { encodeSegment, privateRoot, saveTextFile, sessionDir } from '@deepseek-ai/dsh-spill-local'
|
||||
|
||||
let root: string
|
||||
|
||||
@@ -106,33 +106,34 @@ describe('privateRoot', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalSpillFiles service', () => {
|
||||
it('registers as ctx.spillFiles and saves under the configured root', async () => {
|
||||
describe('LocalSpillStore service', () => {
|
||||
it('registers as ctx.spillStore and saves under the configured root', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSpillFiles, { root })
|
||||
const ref = await ctx.spillFiles.saveText(request())
|
||||
expect(dirname(ref.path)).toBe(sessionDir(root, 'sess-1'))
|
||||
expect(readFileSync(ref.path, 'utf8')).toBe('the full body')
|
||||
await ctx.plugin(LocalSpillStore, { root })
|
||||
const ref = await ctx.spillStore.saveText(request())
|
||||
expect(dirname(ref.locator)).toBe(sessionDir(root, 'sess-1'))
|
||||
expect(readFileSync(ref.locator, 'utf8')).toBe('the full body')
|
||||
expect(ref.bytes).toBe(Buffer.byteLength('the full body', 'utf8'))
|
||||
expect(ref.retrievalHint).toBe('Use read with offset/limit, or grep this path to search within it.')
|
||||
})
|
||||
|
||||
it('resolves a relative configured root to absolute', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSpillFiles, { root: '.' })
|
||||
expect(isAbsolute((ctx.spillFiles as LocalSpillFiles).root)).toBe(true)
|
||||
await ctx.plugin(LocalSpillStore, { root: '.' })
|
||||
expect(isAbsolute((ctx.spillStore as LocalSpillStore).root)).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the private root when none is configured', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSpillFiles, {})
|
||||
expect((ctx.spillFiles as LocalSpillFiles).root).toBe(privateRoot())
|
||||
await ctx.plugin(LocalSpillStore, {})
|
||||
expect((ctx.spillStore as LocalSpillStore).root).toBe(privateRoot())
|
||||
})
|
||||
|
||||
it('rejects when the root is not writable (missing parent, exclusive open)', async () => {
|
||||
const ctx = new Context()
|
||||
// A file (not a dir) as the root makes mkdir under it fail — a real storage error.
|
||||
const filePath = (await saveTextFile({ root, sessionId: 's', suggestedName: 'f', content: 'x' })).path
|
||||
await ctx.plugin(LocalSpillFiles, { root: filePath })
|
||||
await expect(ctx.spillFiles.saveText(request())).rejects.toThrow()
|
||||
await ctx.plugin(LocalSpillStore, { root: filePath })
|
||||
await expect(ctx.spillStore.saveText(request())).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# @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.
|
||||
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 through [`ctx.spillStore`](../spill) and replaces the model-facing result with a bounded head/tail preview plus the backend's locator and retrieval hint.
|
||||
|
||||
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.
|
||||
This plugin registers **no service** and owns no storage or preview mechanics: preview is [`@deepseek-ai/dsh-retention`](../../util/retention) (`TextRetainer`), storage is `ctx.spillStore`. It only decides WHEN to spill and composes the notice.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -13,7 +13,7 @@ This plugin registers **no service** and owns no storage or preview mechanics: p
|
||||
## 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).
|
||||
2. Skip `read` (avoids a `read → spill → 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, sized so the whole replacement (preview + blank line + notice) stays within `maxInlineBytes` — the notice's byte cost is reserved out of the budget, so the preview shrinks to fit and the model-facing result never exceeds the cap:
|
||||
@@ -21,13 +21,13 @@ This plugin registers **no service** and owns no storage or preview mechanics: p
|
||||
```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.)
|
||||
(Omitted N bytes. Full formatted result stored at: /…/session-…/…-web_fetch.txt. Use read with offset/limit, or grep this path to search within it.)
|
||||
```
|
||||
|
||||
When the notice alone fills the budget (a tiny cap or a long path) the preview is empty and only the notice is returned. If even that notice-only replacement would exceed `maxInlineBytes`, the policy keeps the inline result — it never emits a replacement over the cap (and a within-cap replacement is always smaller than the original, so this also means spilling never adds bytes).
|
||||
When the notice alone fills the budget (a tiny cap or a long locator) the preview is empty and only the notice is returned. If even that notice-only replacement would exceed `maxInlineBytes`, the policy keeps the inline result — it never emits a replacement over the cap (and a within-cap replacement is always smaller than the original, so this also means spilling never adds bytes).
|
||||
|
||||
**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.
|
||||
**Best-effort:** no session owner, no `ctx.spillStore` 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).
|
||||
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 artifact 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).
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* 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.
|
||||
* session-scoped spill artifact (`ctx.spillStore`) and replaces the
|
||||
* model-facing result with a bounded head/tail preview plus the backend's
|
||||
* locator and retrieval guidance.
|
||||
*
|
||||
* It registers NO service and owns NO storage or preview mechanics: preview is
|
||||
* `@deepseek-ai/dsh-retention` (`TextRetainer`), storage is `ctx.spillFiles`.
|
||||
* `@deepseek-ai/dsh-retention` (`TextRetainer`), storage is `ctx.spillStore`.
|
||||
* The policy only decides WHEN to spill and composes the notice.
|
||||
*
|
||||
* ## Deliberately narrow
|
||||
@@ -16,8 +16,8 @@
|
||||
* - 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
|
||||
* - `read` is skipped to avoid a `read → spill → read again` loop.
|
||||
* - Best-effort: no session owner, no `ctx.spillStore` 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.
|
||||
*
|
||||
@@ -34,7 +34,7 @@ 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 { SaveTextSpill, SpillRef } 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'
|
||||
@@ -86,10 +86,10 @@ function preview(text: string, budget: number): { text: string; omitted: Omitted
|
||||
return { text: kept.text, omitted: kept.omittedBytes }
|
||||
}
|
||||
|
||||
/** The spill-notice line for a given omission + path (no preview, no leading blank line). */
|
||||
function spillNotice(omitted: Omitted, spillPath: string): string {
|
||||
/** The spill-notice line for a given omission + saved reference (no preview, no leading blank line). */
|
||||
function spillNotice(omitted: Omitted, ref: SpillRef): string {
|
||||
const omission = describeOmitted(omitted, 'bytes')
|
||||
return `(${omission} Full formatted result saved to: ${spillPath}. Use read with offset/limit to inspect it.)`
|
||||
return `(${omission} Full formatted result stored at: ${ref.locator}. ${ref.retrievalHint})`
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
@@ -108,7 +108,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// 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.
|
||||
// Skip `read` to avoid a read → spill → read again loop.
|
||||
if (decision.kind !== 'accept' || exec.name === 'read') return decision
|
||||
|
||||
const content = decision.content ?? result.content
|
||||
@@ -122,9 +122,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
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')
|
||||
const spillStore = ctx.get('spillStore')
|
||||
if (!spillStore) {
|
||||
ctx.logger.warn('spill-policy: no ctx.spillStore backend loaded; keeping the inline result')
|
||||
return decision
|
||||
}
|
||||
|
||||
@@ -134,9 +134,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
suggestedName: `${exec.name}.txt`,
|
||||
content: text,
|
||||
}
|
||||
let path: string
|
||||
let ref: SpillRef
|
||||
try {
|
||||
({ path } = await spillFiles.saveText(save))
|
||||
ref = await spillStore.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.
|
||||
@@ -152,10 +152,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// count (the full byte total): its digit count bounds the real count's, so
|
||||
// the reserved size is a safe upper bound and the final notice is never
|
||||
// longer than what we reserved. `\n\n` is the 2-byte join.
|
||||
const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, path), 'utf8') + 2
|
||||
const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2
|
||||
const previewBudget = Math.max(0, maxInlineBytes - reserve)
|
||||
const { text: previewText, omitted } = preview(text, previewBudget)
|
||||
const notice = spillNotice(omitted, path)
|
||||
const notice = spillNotice(omitted, ref)
|
||||
const replacedText = previewText.length > 0 ? `${previewText}\n\n${notice}` : notice
|
||||
// Invariant: the policy NEVER emits a replacement larger than the cap. When
|
||||
// the notice alone exceeds maxInlineBytes (a tiny cap or a long spill root),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 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.
|
||||
* execution the policy needs to derive the owning session for a spill artifact.
|
||||
*
|
||||
* `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies this shape, so the policy
|
||||
* reads `exec` straight through without importing `dsh-tools` or `dsh-agent`.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 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,
|
||||
* oversized plain-text result is spilled and replaced with a preview + locator,
|
||||
* 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`.
|
||||
@@ -17,19 +17,23 @@ 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 { SpillLocator, SpillStore } 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 {
|
||||
class StubStore extends SpillStore {
|
||||
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') }
|
||||
return {
|
||||
locator: SpillLocator(`/spill/${input.suggestedName}`),
|
||||
bytes: Buffer.byteLength(input.content, 'utf8'),
|
||||
retrievalHint: 'Use the stub retrieval path.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,14 +58,14 @@ function exec(name: string, session = 's1'): 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; fiber: Awaited<ReturnType<Context['plugin']>> }> {
|
||||
async function setup(config: SpillPolicy.Config, withSpill = true): Promise<{ ctx: Context; spill?: StubStore; fiber: Awaited<ReturnType<Context['plugin']>> }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
let spill: StubSpill | undefined
|
||||
let spill: StubStore | undefined
|
||||
if (withSpill) {
|
||||
await ctx.plugin(StubSpill)
|
||||
spill = ctx.spillFiles as StubSpill
|
||||
await ctx.plugin(StubStore)
|
||||
spill = ctx.spillStore as StubStore
|
||||
}
|
||||
const fiber = await ctx.plugin(SpillPolicy, config)
|
||||
return { ctx, fiber, ...spill ? { spill } : {} }
|
||||
@@ -108,7 +112,7 @@ describe('config validation', () => {
|
||||
})
|
||||
|
||||
describe('oversized plain-text replacement', () => {
|
||||
it('spills the full text and replaces the result with a preview + path within the cap', async () => {
|
||||
it('spills the full text and replaces the result with a preview + locator within the cap', async () => {
|
||||
const { ctx, spill } = await setup({ maxInlineBytes: 200 })
|
||||
const body = 'HEAD'.repeat(200) + 'TAIL'.repeat(200) // 1600 bytes > 200
|
||||
ctx.tools.register(textTool('big', body))
|
||||
@@ -124,8 +128,8 @@ describe('oversized plain-text replacement', () => {
|
||||
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('Full formatted result stored at: /spill/big.txt')
|
||||
expect(text).toContain('Use the stub retrieval path.')
|
||||
expect(text).toContain('Omitted')
|
||||
// The replacement (preview + blank line + notice) stays within the cap and
|
||||
// is smaller than the original — the whole point of spilling.
|
||||
@@ -221,7 +225,7 @@ describe('composition', () => {
|
||||
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')
|
||||
expect(textOf(result.content)).toContain('Full formatted result stored at')
|
||||
})
|
||||
|
||||
it('preserves a downstream accept decision additionalContext when spilling', async () => {
|
||||
@@ -231,7 +235,7 @@ describe('composition', () => {
|
||||
({ 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(textOf(result.content)).toContain('Full formatted result stored at')
|
||||
expect(result.additionalContext).toEqual(context)
|
||||
})
|
||||
})
|
||||
@@ -259,7 +263,7 @@ describe('disposal (HMR safety)', () => {
|
||||
|
||||
// Live: the listener spills and replaces.
|
||||
const before = await ctx.tools.execute(exec('big'))
|
||||
expect(textOf(before.content)).toContain('Full formatted result saved to')
|
||||
expect(textOf(before.content)).toContain('Full formatted result stored at')
|
||||
expect(spill?.saves).toHaveLength(1)
|
||||
|
||||
// After disposal the listener is gone — the result passes through untouched
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @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.
|
||||
The **spill storage seam**: an abstract `SpillStore` service (`ctx.spillStore`) defining WHAT a spill backend does — persist a tool's oversized text and return a model-facing locator plus retrieval guidance — without saying HOW.
|
||||
|
||||
This package is one third of the spill capability, split so each concern evolves (and swaps) independently:
|
||||
|
||||
@@ -10,18 +10,18 @@ This package is one third of the spill capability, split so each concern evolves
|
||||
| `@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.
|
||||
The split mirrors the bash/fs seams. A future remote or virtual backend (e.g. a `spill://…` URI, a database key, or a backend-specific retrieval tool) implements this interface without touching the policy plugin.
|
||||
|
||||
## Service API (`ctx.spillFiles`)
|
||||
## Service API (`ctx.spillStore`)
|
||||
|
||||
| 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. |
|
||||
| `saveText(input)` | Persist `input.content` verbatim; resolves with a `SpillRef` (opaque locator, exact bytes written, and retrieval hint). **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).
|
||||
Storage is scoped by the request's `owner` session; the backend chooses its own private representation and may derive names from — never trust as a path — 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 retrieval/search API (the backend's `retrievalHint` tells the model what to do with the locator).
|
||||
|
||||
## 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.
|
||||
`SaveTextSpill` (owner, source, suggestedName, content) is the request; `SpillRef` (locator, bytes, retrievalHint) is the result. `SpillLocator` is [branded](../../util/brand) and rendered to the model as an opaque string — a local path for `dsh-spill-local`, but a future backend may return a URI, key, or command token without changing policy/tool consumers. `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 backend naming 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
/**
|
||||
* 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;
|
||||
* The spill storage seam (`ctx.spillStore`): an abstract service defining WHAT a
|
||||
* spill backend does — persist a tool's oversized text and return a model-facing
|
||||
* locator plus retrieval guidance — without saying HOW. Implementations
|
||||
* subclass {@link SpillStore} and register as the `spillStore` 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.
|
||||
* replacement (that is `@deepseek-ai/dsh-spill-policy`), and NO retrieval or
|
||||
* search API. The backend supplies the locator and retrieval hint appropriate
|
||||
* for its storage substrate.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-spill
|
||||
*/
|
||||
@@ -18,24 +17,24 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { SaveTextSpill, SpillRef } from './types.ts'
|
||||
|
||||
export { SpillPath } from './types.ts'
|
||||
export { SpillLocator } from './types.ts'
|
||||
export type { SaveTextSpill, SpillOwner, SpillRef, SpillSource } from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
spillFiles: SpillFiles
|
||||
spillStore: SpillStore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract spill storage service. Subclass, implement {@link saveText}, and load
|
||||
* the subclass as a plugin — it registers as `ctx.spillFiles` (one
|
||||
* the subclass as a plugin — it registers as `ctx.spillStore` (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.
|
||||
* - {@link saveText} persists the FULL `content` verbatim and returns an opaque
|
||||
* locator, exact byte length, and model-facing retrieval guidance.
|
||||
* - 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`.
|
||||
@@ -43,18 +42,17 @@ declare module 'cordis' {
|
||||
* 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 {
|
||||
export abstract class SpillStore extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'spillFiles')
|
||||
super(ctx, 'spillStore')
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist `input.content` to a session-scoped spill file.
|
||||
* Persist `input.content` to a session-scoped spill artifact.
|
||||
* @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.
|
||||
* @returns the saved artifact's {@link SpillRef}; rejects on a storage failure.
|
||||
*/
|
||||
abstract saveText(input: SaveTextSpill): Promise<SpillRef>
|
||||
}
|
||||
|
||||
export default SpillFiles
|
||||
export default SpillStore
|
||||
|
||||
@@ -11,22 +11,20 @@ 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.
|
||||
* Opaque model-facing handle for one spilled artifact. A local backend may use a
|
||||
* filesystem path; a remote or database backend may use a URI or key. Consumers
|
||||
* render it with {@link SpillRef.retrievalHint}, but do not parse it.
|
||||
*/
|
||||
export type SpillPath = Branded<'SpillPath'>
|
||||
export type SpillLocator = Branded<'SpillLocator'>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link SpillPath}.
|
||||
* Brand a string as a {@link SpillLocator}.
|
||||
*
|
||||
* @param path The backend-produced path string to brand.
|
||||
* @returns The branded spill path.
|
||||
* @param locator The backend-produced locator string to brand.
|
||||
* @returns The branded spill locator.
|
||||
*/
|
||||
export function SpillPath(path: string): SpillPath {
|
||||
return path as SpillPath
|
||||
export function SpillLocator(locator: string): SpillLocator {
|
||||
return locator as SpillLocator
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +51,7 @@ export interface SpillSource {
|
||||
label: string
|
||||
}
|
||||
|
||||
/** One request to persist text to a spill file. */
|
||||
/** One request to persist text to a spill artifact. */
|
||||
export interface SaveTextSpill {
|
||||
owner: SpillOwner
|
||||
source: SpillSource
|
||||
@@ -66,8 +64,9 @@ export interface SaveTextSpill {
|
||||
content: string
|
||||
}
|
||||
|
||||
/** A saved spill file: its path plus the byte length written. */
|
||||
/** A saved spill artifact: its locator, byte length, and backend-specific retrieval guidance. */
|
||||
export interface SpillRef {
|
||||
path: SpillPath
|
||||
locator: SpillLocator
|
||||
bytes: number
|
||||
retrievalHint: string
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Tests for the spill seam INTERFACE: a minimal concrete subclass registers as
|
||||
* `ctx.spillFiles`, a second load throws (duplicate service), and disposal
|
||||
* `ctx.spillStore`, 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.
|
||||
*/
|
||||
@@ -9,16 +9,20 @@ 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 { SpillLocator, SpillStore } 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 {
|
||||
class StubStore extends SpillStore {
|
||||
last: SaveTextSpill | undefined
|
||||
|
||||
async saveText(input: SaveTextSpill): Promise<SpillRef> {
|
||||
this.last = input
|
||||
return { path: SpillPath(`/stub/${input.suggestedName}`), bytes: Buffer.byteLength(input.content, 'utf8') }
|
||||
return {
|
||||
locator: SpillLocator(`/stub/${input.suggestedName}`),
|
||||
bytes: Buffer.byteLength(input.content, 'utf8'),
|
||||
retrievalHint: 'Use the stub reader.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,25 +36,25 @@ function request(content: string): SaveTextSpill {
|
||||
}
|
||||
|
||||
describe('spill seam', () => {
|
||||
it('registers as ctx.spillFiles and saves text', async () => {
|
||||
it('registers as ctx.spillStore 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')
|
||||
await ctx.plugin(StubStore)
|
||||
const ref = await ctx.spillStore.saveText(request('hello'))
|
||||
expect(ref).toEqual({ locator: '/stub/web_fetch.txt', bytes: 5, retrievalHint: 'Use the stub reader.' })
|
||||
expect((ctx.spillStore as StubStore).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()
|
||||
await ctx.plugin(StubStore)
|
||||
await expect(ctx.plugin(StubStore)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('releases the service on disposal', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(StubSpill)
|
||||
expect(ctx.spillFiles).toBeInstanceOf(StubSpill)
|
||||
const fiber = await ctx.plugin(StubStore)
|
||||
expect(ctx.spillStore).toBeInstanceOf(StubStore)
|
||||
await fiber.dispose()
|
||||
expect((ctx as Context & { spillFiles?: unknown }).spillFiles).toBeUndefined()
|
||||
expect((ctx as Context & { spillStore?: unknown }).spillStore).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,8 +61,8 @@ function scrubString(value: string, ctx: NormalizeContext): string {
|
||||
// residual UUID (covers ids that appear in places we didn't enumerate).
|
||||
out = out.split(ctx.cwd).join(CWD)
|
||||
out = out.split(`/private${CWD}`).join(CWD)
|
||||
out = out.replace(LOCAL_SPILL_PATH_RE, (_match, name: string) => `{{spillPath:${name}}}`)
|
||||
out = out.replace(SNAPSHOT_SPILL_PATH_RE, (_match, name: string) => `{{spillPath:${name}}}`)
|
||||
out = out.replace(LOCAL_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`)
|
||||
out = out.replace(SNAPSHOT_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`)
|
||||
for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID)
|
||||
out = out.replace(UUID_RE, SESSION_ID)
|
||||
return out
|
||||
|
||||
@@ -98,12 +98,12 @@ describe('normalizeSessionLog', () => {
|
||||
data: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Full formatted result saved to: ${ctx.cwd}/.spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit to inspect it.`,
|
||||
text: `Full formatted result stored at: ${ctx.cwd}/.spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`,
|
||||
}],
|
||||
},
|
||||
})
|
||||
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
|
||||
expect(out).toContain('{{spillPath:bash.txt}}')
|
||||
expect(out).toContain('{{spillLocator:bash.txt}}')
|
||||
expect(out).not.toContain('session-c22bc3f1d2af')
|
||||
expect(out).not.toContain('8a7b6c5d4e3f')
|
||||
})
|
||||
@@ -114,13 +114,13 @@ describe('normalizeSessionLog', () => {
|
||||
data: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Full formatted result saved to: /private${ctx.cwd}/.spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit to inspect it.`,
|
||||
text: `Full formatted result stored at: /private${ctx.cwd}/.spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`,
|
||||
}],
|
||||
},
|
||||
})
|
||||
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
|
||||
expect(out).toContain('{{spillPath:bash.txt}}')
|
||||
expect(out).not.toContain('/private{{spillPath')
|
||||
expect(out).toContain('{{spillLocator:bash.txt}}')
|
||||
expect(out).not.toContain('/private{{spillLocator')
|
||||
})
|
||||
|
||||
it('scrubs fixed snapshot spill paths', () => {
|
||||
@@ -129,12 +129,12 @@ describe('normalizeSessionLog', () => {
|
||||
data: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Full formatted result saved to: /tmp/dsh-acp-snapshot-spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit to inspect it.',
|
||||
text: 'Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.',
|
||||
}],
|
||||
},
|
||||
})
|
||||
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
|
||||
expect(out).toContain('{{spillPath:bash.txt}}')
|
||||
expect(out).toContain('{{spillLocator:bash.txt}}')
|
||||
expect(out).not.toContain('/tmp/dsh-acp-snapshot-spill')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* 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).
|
||||
* `ctx.tools.execute()`. Proves the RFC's default local-backend 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'
|
||||
@@ -21,7 +21,7 @@ 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 LocalSpillStore from '@deepseek-ai/dsh-spill-local'
|
||||
import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
|
||||
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
|
||||
|
||||
@@ -50,7 +50,7 @@ beforeEach(async () => {
|
||||
// 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(LocalSpillStore, { root: spillRoot })
|
||||
await ctx.plugin(SpillPolicy, { maxInlineBytes: MAX_INLINE_BYTES })
|
||||
await ctx.plugin(ToolWeb)
|
||||
})
|
||||
@@ -68,7 +68,7 @@ function fetchCall(): Promise<{ isError: boolean; content: { type: string; text?
|
||||
}
|
||||
|
||||
describe('web_fetch spill showcase', () => {
|
||||
it('spills a large formatted result and returns a preview + spill path', async () => {
|
||||
it('spills a large formatted result and returns a preview + spill locator', async () => {
|
||||
const out = await fetchCall()
|
||||
expect(out.isError).toBe(false)
|
||||
const text = out.content.map(b => b.text).join('')
|
||||
@@ -77,11 +77,11 @@ describe('web_fetch spill showcase', () => {
|
||||
expect(text.length).toBeLessThan(BODY.length)
|
||||
expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(MAX_INLINE_BYTES)
|
||||
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')
|
||||
expect(text).toContain('Full formatted result stored at:')
|
||||
expect(text).toContain('Use read with offset/limit, or grep this path')
|
||||
|
||||
// The spill file holds the FULL formatted result the tool returned.
|
||||
const match = /saved to: (\S+?)\. Use read/.exec(text)
|
||||
const match = /stored at: (\S+?)\. Use read/.exec(text)
|
||||
expect(match).not.toBeNull()
|
||||
const spillPath = match![1]!
|
||||
const saved = readFileSync(spillPath, 'utf8')
|
||||
|
||||
Reference in New Issue
Block a user