fix: make search raw output recovery backend-neutral

This commit is contained in:
Dudu-0223
2026-07-10 11:53:02 +08:00
parent 1df9f3a84a
commit 3bb90bd4b6
16 changed files with 136 additions and 107 deletions

View File

@@ -39,8 +39,8 @@ Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`c
## Two budgets, two artifacts
Raw `rg` stdout is an internal transport detail. When the executor truncates it, the tool recovers the complete stream from the executor's **raw bash spill file** — read locally, capped at `rawOutputMaxBytes`, never shown to the model. 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.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`.
## Errors
Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (missing `rg`, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or truncated with no recovery file), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.
Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (missing `rg`, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.

View File

@@ -7,17 +7,15 @@
* Both tools execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)`
* as ordinary foreground tool calls — never `ctx.bash.start()`, never a
* model-visible background task. Raw `rg` stdout is an internal transport
* detail: when the executor truncates it, the ONLY recovery source is the
* executor's local raw spill file, read here up to `rawOutputMaxBytes` and
* never exposed to the model. The model-facing recovery artifact is the
* 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()`
* ({@link trySaveFormattedResult}) — a different artifact from the bash raw
* spill file.
* ({@link trySaveFormattedResult}).
*
* @module @deepseek-ai/dsh-tool-fs-search/search-core
*/
import { readFile, stat } from 'node:fs/promises'
import { isAbsolute, relative, sep } from 'node:path'
import type { Context } from 'cordis'
import { HarnessError } from '@deepseek-ai/dsh-llm'
@@ -45,7 +43,7 @@ export const SEARCH_TIMEOUT_MS = 30_000
* glob; `SEARCH_FAILED` — the search could not run or its output could not be
* parsed (missing `rg`, inaccessible target, signal kill, malformed `--json`);
* `SEARCH_RAW_OUTPUT_OVERFLOW` — raw `rg` output exceeded `rawOutputMaxBytes`
* (or was truncated with no recovery file); `SEARCH_ABORTED` — the tool
* or stayed truncated after that requested stdout budget; `SEARCH_ABORTED` — the tool
* timeout, caller cancellation, or the bash executor's own timeout cut the
* search short.
*/
@@ -72,7 +70,7 @@ export class SearchError extends HarnessError {
/** The completed acquisition of one `rg` run: complete stdout plus the resolved workdir. */
export interface RipgrepRun {
/** Complete raw stdout — inline executor text, or the raw spill file's content. */
/** Complete raw stdout retained by the bash executor within the requested cap. */
stdout: string
/** True when ripgrep exited 1: a successful search with zero results. */
noMatches: boolean
@@ -104,14 +102,11 @@ function classifyRunFailure(toolName: string, result: BashRunResult): SearchErro
/**
* Acquire the COMPLETE raw stdout of a finished run, enforcing
* `rawOutputMaxBytes` on BOTH transports: inline executor text (an executor
* retaining more than this package's cap must not smuggle an over-cap parse
* through the untruncated path) and the executor's local raw spill file, read
* only when the complete file fits the cap. A missing spill path or over-cap
* output is a clear failure telling the model to narrow the search — never a
* silently-partial parse.
* `rawOutputMaxBytes` on the in-memory transport. A truncated result means the
* bash backend could not retain complete stdout within the requested budget, so
* the tool fails clearly instead of parsing a silently-partial stream.
*/
async function completeStdout(toolName: string, result: BashRunResult, rawOutputMaxBytes: number): Promise<string> {
function completeStdout(toolName: string, result: BashRunResult, rawOutputMaxBytes: number): string {
const narrow = 'narrow pattern, path, or include and retry'
if (!result.stdout.truncated) {
const inlineBytes = Buffer.byteLength(result.stdout.text, 'utf8')
@@ -123,26 +118,10 @@ async function completeStdout(toolName: string, result: BashRunResult, rawOutput
}
return result.stdout.text
}
const spillPath = result.stdout.spillPath
if (spillPath === undefined) {
throw new SearchError(
`${toolName} produced more raw output than the bash executor retained and no raw spill file is available; ${narrow}`,
'SEARCH_RAW_OUTPUT_OVERFLOW',
)
}
try {
const { size } = await stat(spillPath)
if (size > rawOutputMaxBytes) {
throw new SearchError(
`${toolName} produced ${size} bytes of raw output, over the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
'SEARCH_RAW_OUTPUT_OVERFLOW',
)
}
return await readFile(spillPath, 'utf8')
} catch (error: unknown) {
if (error instanceof SearchError) throw error
throw new SearchError(`${toolName} could not read the executor's raw output spill file`, 'SEARCH_FAILED', { cause: error })
}
throw new SearchError(
`${toolName} produced more raw output than the bash executor retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
'SEARCH_RAW_OUTPUT_OVERFLOW',
)
}
/**
@@ -181,6 +160,7 @@ export async function runRipgrep(
const cwd = exec.agent?.session.header.cwd
const spec = ctx.bash.resolve({
command,
stdoutMaxBytes: rawOutputMaxBytes,
...cwd !== undefined ? { workdir: cwd } : {},
...exec.signal ? { signal: exec.signal } : {},
})
@@ -208,7 +188,7 @@ export async function runRipgrep(
if (result.exitCode !== 0 && result.exitCode !== 1) {
throw classifyRunFailure(toolName, result)
}
const stdout = await completeStdout(toolName, result, rawOutputMaxBytes)
const stdout = completeStdout(toolName, result, rawOutputMaxBytes)
return { stdout, noMatches: result.exitCode === 1, workdir: spec.workdir }
}

View File

@@ -2,7 +2,7 @@
* Consumer-surface tests for the search tools over a FAKE bash executor and a
* FAKE spill backend, exercised through `ctx.tools.execute()` so nothing
* bypasses the tool registry. The fake executor makes every seam outcome
* scriptable — truncated stdout with/without a raw spill file, abort/timeout,
* scriptable — truncated stdout with/without a raw spill path, abort/timeout,
* signal kills, ripgrep exit codes — so these tests verify schemas, argument
* validation, shell-safe command construction, workdir derivation, signal
* forwarding, `SEARCH_*` error classification, retention, formatted-result
@@ -10,10 +10,7 @@
* pinned separately in integration.spec.ts.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
@@ -66,6 +63,7 @@ class FakeBash extends BashExecutor {
command: request.command,
workdir: request.workdir ?? '/work',
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
owner: request.owner,
}
@@ -388,28 +386,18 @@ describe('exit semantics and failure classification', () => {
})
describe('raw output acquisition', () => {
let dir: string
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
it('passes rawOutputMaxBytes to bash as the stdout capture budget', async () => {
const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 1234 } })
bash.handler = () => runResult('', { exitCode: 1 })
await call(ctx, 'glob', { pattern: '*.ts' })
await call(ctx, 'grep', { pattern: 'needle' })
expect(bash.requests.map(request => request.stdoutMaxBytes)).toEqual([1234, 1234])
expect(bash.specs.map(spec => spec.stdoutMaxBytes)).toEqual([1234, 1234])
})
it('parses the complete raw spill file when stdout is truncated', async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-search-raw-'))
const spillPath = join(dir, 'raw.txt')
await writeFile(spillPath, 'one.ts\ntwo.ts\nthree.ts\n')
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { stdout: { text: 'one.ts\n', truncated: true, spillPath } })
const result = await call(ctx, 'glob', { pattern: '*.ts' })
expect(result.isError).toBe(false)
expect(text(result)).toBe('one.ts\ntwo.ts\nthree.ts')
})
it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when the raw spill file exceeds the cap', async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-search-raw-'))
const spillPath = join(dir, 'raw.txt')
await writeFile(spillPath, 'x'.repeat(64))
it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when truncated stdout has a raw spill path', async () => {
const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } })
bash.handler = () => runResult('', { stdout: { text: 'x', truncated: true, spillPath } })
bash.handler = () => runResult('', { stdout: { text: 'x', truncated: true, spillPath: '/does/not/get-read' } })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
expect(text(result)).toContain('narrow pattern, path, or include')
@@ -419,7 +407,6 @@ describe('raw output acquisition', () => {
// An executor retaining more inline than this package's cap (or a
// deployment lowering rawOutputMaxBytes below the bash retention) must not
// smuggle an over-cap parse through the untruncated path.
dir = await mkdtemp(join(tmpdir(), 'dsh-search-raw-'))
const { ctx, bash } = await setup({ config: { rawOutputMaxBytes: 16 } })
bash.handler = () => runResult(`${'x'.repeat(64)}\n`)
const result = await call(ctx, 'grep', { pattern: 'x' })
@@ -428,21 +415,11 @@ describe('raw output acquisition', () => {
})
it('fails with SEARCH_RAW_OUTPUT_OVERFLOW when truncated stdout has no spill path', async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-search-raw-'))
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { stdout: { text: 'partial', truncated: true } })
const result = await call(ctx, 'grep', { pattern: 'x' })
expect(result.error).toMatchObject({ code: 'SEARCH_RAW_OUTPUT_OVERFLOW' })
})
it('fails with SEARCH_FAILED when the raw spill file cannot be read', async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-search-raw-'))
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { stdout: { text: 'partial', truncated: true, spillPath: join(dir, 'gone.txt') } })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.error).toMatchObject({ code: 'SEARCH_FAILED' })
expect(text(result)).toContain('raw output spill file')
})
})
describe('glob results', () => {