feat: bash-backed glob/grep discovery tools (dsh-tool-fs-search)
Implements docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob- discovery.md: model-facing glob/grep in a new @deepseek-ai/dsh-tool-fs-search package, executing fixed ripgrep templates through ctx.bash.resolve/run — not ctx.fs provider methods — so filesystem backends stay free of a search contract and sandboxed/remote executors substitute cleanly. The tools never call ctx.bash.start(); the tool layer owns quoting (one singleQuote safety boundary), rg --json parsing, ItemRetainer/TextRetainer retention, and the first tool-owned ctx.spillFiles.saveText() handoff (item-level retention the generic post-execute spill policy cannot recover). RFC amendments on the way to implemented/: a shared src/search-core.ts (the SEARCH_* vocabulary + bash-run/raw-spill/spill plumbing was byte-identical across both tools — the missed-extraction smell), and a snapshot-gap note: wiring the acp-agent tree changes the assembled prompt, so goldens need a keyed re-record; the spill notice text is pinned by unit tests instead and only the coding-agent example ships the tools for now.
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Shared execution plumbing for the `glob` / `grep` search tools: the
|
||||
* package-owned `SEARCH_*` error vocabulary, one bash-seam run helper that
|
||||
* turns a fixed `rg` command into complete raw stdout, the best-effort
|
||||
* formatted-result spill handoff, and workdir-relative path display.
|
||||
*
|
||||
* 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
|
||||
* formatted result saved through `ctx.spillFiles.saveText()`
|
||||
* ({@link trySaveFormattedResult}) — a different artifact from the bash raw
|
||||
* spill file.
|
||||
*
|
||||
* @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'
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SaveTextSpill } from '@deepseek-ai/dsh-spill'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/**
|
||||
* Default cap on the complete raw `rg` stdout the tools will parse (the
|
||||
* `rawOutputMaxBytes` config), matching Claude Code's ripgrep raw buffer.
|
||||
*/
|
||||
export const RAW_OUTPUT_MAX_BYTES = 20_000_000
|
||||
|
||||
/**
|
||||
* Default cooperative tool-call timeout budget in milliseconds (the `timeoutMs`
|
||||
* config), attached to both tool definitions for
|
||||
* `@deepseek-ai/dsh-timeout-policy` to enforce through `exec.signal`.
|
||||
*/
|
||||
export const SEARCH_TIMEOUT_MS = 30_000
|
||||
|
||||
/**
|
||||
* Stable, machine-routable codes for search failures. Package-owned (not
|
||||
* `FsErrorCode`) because these tools are bash-backed discovery, not `ctx.fs`
|
||||
* provider operations: `SEARCH_INVALID_PATTERN` — ripgrep rejected the regex or
|
||||
* 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
|
||||
* timeout, caller cancellation, or the bash executor's own timeout cut the
|
||||
* search short.
|
||||
*/
|
||||
export type SearchErrorCode =
|
||||
| 'SEARCH_INVALID_PATTERN'
|
||||
| 'SEARCH_FAILED'
|
||||
| 'SEARCH_RAW_OUTPUT_OVERFLOW'
|
||||
| 'SEARCH_ABORTED'
|
||||
|
||||
/**
|
||||
* Typed search failure. Extends {@link HarnessError} so it carries a stable
|
||||
* {@link SearchErrorCode} and chains `cause`; the tool registry surfaces
|
||||
* `{ name, code }` on `isError` results so retry/permission/UI layers can
|
||||
* branch without parsing messages.
|
||||
*/
|
||||
export class SearchError extends HarnessError {
|
||||
override readonly code: SearchErrorCode
|
||||
|
||||
constructor(message: string, code: SearchErrorCode, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
stdout: string
|
||||
/** True when ripgrep exited 1: a successful search with zero results. */
|
||||
noMatches: boolean
|
||||
/** The resolved working directory the command ran in (the display-relativization base). */
|
||||
workdir: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The retained stderr tail as a diagnostic excerpt, with a truncation note when
|
||||
* the executor dropped bytes (the tool never reads `stderr.spillPath`).
|
||||
*/
|
||||
function stderrExcerpt(stderr: CollectedOutput): string {
|
||||
const text = stderr.text.trim()
|
||||
if (text.length === 0) return ''
|
||||
return stderr.truncated ? `${text} [stderr truncated]` : text
|
||||
}
|
||||
|
||||
/** Classify a nonzero-exit `rg` run into the search error vocabulary (invalid pattern vs missing `rg` vs everything else). */
|
||||
function classifyRunFailure(toolName: string, result: BashRunResult): SearchError {
|
||||
const stderr = stderrExcerpt(result.stderr)
|
||||
if (/regex parse error|error parsing glob/i.test(stderr)) {
|
||||
return new SearchError(`${toolName} pattern rejected by ripgrep: ${stderr}`, 'SEARCH_INVALID_PATTERN')
|
||||
}
|
||||
if (result.exitCode === 127 || /command not found/i.test(stderr)) {
|
||||
return new SearchError(`${toolName} requires ripgrep (rg) on the bash executor's PATH${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
|
||||
}
|
||||
return new SearchError(`${toolName} search failed (exit ${result.exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the COMPLETE raw stdout of a finished run. Untruncated stdout is used
|
||||
* as-is; truncated stdout is recovered from the executor's local raw spill file
|
||||
* only when the complete file fits within `rawOutputMaxBytes`. A missing spill
|
||||
* path or an over-cap file is a clear failure telling the model to narrow the
|
||||
* search — never a silently-partial parse.
|
||||
*/
|
||||
async function completeStdout(toolName: string, result: BashRunResult, rawOutputMaxBytes: number): Promise<string> {
|
||||
if (!result.stdout.truncated) return result.stdout.text
|
||||
const narrow = 'narrow pattern, path, or include and retry'
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one fixed `rg` command through the bash seam and return its complete raw
|
||||
* stdout. The bash request workdir is the calling agent's session cwd
|
||||
* (`exec.agent.session.header.cwd`) when available — mirroring `dsh-tool-bash` /
|
||||
* `dsh-tool-fs` — else omitted so the implementation's `resolve()` applies its
|
||||
* configured default. `exec.signal` is forwarded so the cooperative tool
|
||||
* timeout (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation kill the
|
||||
* command; the bash backend's own timeout stays a second safety cap.
|
||||
*
|
||||
* Exit semantics are tool-owned: exit 0 is success with results, exit 1 is
|
||||
* success with zero results (`noMatches`), anything else throws a
|
||||
* {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern →
|
||||
* `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` /
|
||||
* `SEARCH_RAW_OUTPUT_OVERFLOW`).
|
||||
*
|
||||
* @param ctx - the plugin context; execution uses its `bash` service.
|
||||
* @param exec - the tool-execution context; supplies the session cwd and the abort signal.
|
||||
* @param toolName - `glob` or `grep`, used in error messages.
|
||||
* @param command - the fully-quoted `rg` command string (every model value already through `singleQuote`).
|
||||
* @param rawOutputMaxBytes - cap on the complete raw stdout the tool will parse.
|
||||
* @returns the complete stdout, the zero-result flag, and the resolved workdir.
|
||||
*/
|
||||
export async function runRipgrep(
|
||||
ctx: Context,
|
||||
exec: ToolExecution,
|
||||
toolName: string,
|
||||
command: string,
|
||||
rawOutputMaxBytes: number,
|
||||
): Promise<RipgrepRun> {
|
||||
const cwd = exec.agent?.session.header.cwd
|
||||
const spec = ctx.bash.resolve({
|
||||
command,
|
||||
...cwd !== undefined ? { workdir: cwd } : {},
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
})
|
||||
const result = await ctx.bash.run(spec)
|
||||
if (result.aborted) {
|
||||
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED')
|
||||
}
|
||||
if (result.timedOut) {
|
||||
throw new SearchError(`${toolName} timed out after ${result.timeoutMs}ms in the bash executor; narrow pattern, path, or include and retry`, 'SEARCH_ABORTED')
|
||||
}
|
||||
if (result.signal !== null || result.exitCode === null) {
|
||||
throw new SearchError(`${toolName} search command was killed by signal ${result.signal ?? '(unknown)'}`, 'SEARCH_FAILED')
|
||||
}
|
||||
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
||||
throw classifyRunFailure(toolName, result)
|
||||
}
|
||||
const stdout = await completeStdout(toolName, result, rawOutputMaxBytes)
|
||||
return { stdout, noMatches: result.exitCode === 1, workdir: spec.workdir }
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an `rg` output path to its display form: absolute paths inside the
|
||||
* resolved bash workdir become workdir-relative; everything else (relative
|
||||
* output, paths outside the workdir) passes through unchanged. Display-only —
|
||||
* returned paths are follow-up-readable in co-located bash/filesystem
|
||||
* deployments where both resolve the same workspace (the documented v1
|
||||
* deployment requirement).
|
||||
*
|
||||
* @param path - one path as ripgrep printed it.
|
||||
* @param workdir - the resolved bash workdir the command ran in.
|
||||
* @returns the workdir-relative display path when possible, else `path` unchanged.
|
||||
*/
|
||||
export function toWorkdirRelative(path: string, workdir: string): string {
|
||||
if (!isAbsolute(path)) return path
|
||||
const rel = relative(workdir, path)
|
||||
if (rel.length === 0) return '.'
|
||||
if (rel === '..' || rel.startsWith(`..${sep}`)) return path
|
||||
return rel
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* warning and returns `undefined` — the caller keeps the inline result and
|
||||
* 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 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.
|
||||
*/
|
||||
export async function trySaveFormattedResult(
|
||||
ctx: Context,
|
||||
exec: ToolExecution,
|
||||
suggestedName: string,
|
||||
content: string,
|
||||
): Promise<string | 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`)
|
||||
return undefined
|
||||
}
|
||||
const save: SaveTextSpill = {
|
||||
owner: { sessionId },
|
||||
source: { toolName: exec.name, callId: exec.callId, label: 'result' },
|
||||
suggestedName,
|
||||
content,
|
||||
}
|
||||
try {
|
||||
const { path } = await spillFiles.saveText(save)
|
||||
return path
|
||||
} 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.
|
||||
ctx.logger.warn(`tool-fs-search: saveText failed for ${exec.name}: ${String(error)}; complete result not saved`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user