/** * 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: 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.spillStore.saveText()` * ({@link trySaveFormattedResult}). * * @module @deepseek-ai/dsh-tool-fs-search/search-core */ import { isAbsolute, relative, sep } from 'node:path' import type { Context } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention' import type { RetainedItems } from '@deepseek-ai/dsh-retention' import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import type { SaveTextSpill, SpillRef } 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 /** * Default cap in bytes on one search's serialized `presentationMeta` (the * `searchMetaMaxBytes` config). The inline match/path caps already bound the item * COUNT, but retained matches of a broad search (many long lines) can still * serialize to hundreds of kilobytes, and `meta` is persisted with the session * log and re-sent on every request. A deployment's final output budget * (`dsh-spill-policy`) only shrinks a result's `content`, never its `meta`, so the * projection owns this cap. 64 KiB holds the full default-capped result of a * typical search while bounding the pathological one. */ export const SEARCH_META_MAX_BYTES = 65_536 /** * 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 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. */ 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 retained by the bash executor within the requested cap. */ 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, enforcing * `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. */ 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') if (inlineBytes > rawOutputMaxBytes) { throw new SearchError( `${toolName} produced ${inlineBytes} bytes of raw output, over the ${rawOutputMaxBytes}-byte cap; ${narrow}`, 'SEARCH_RAW_OUTPUT_OVERFLOW', ) } return result.stdout.text } throw new SearchError( `${toolName} produced more raw output than the bash executor retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`, 'SEARCH_RAW_OUTPUT_OVERFLOW', ) } /** * 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`). A `run()` REJECTION — the seam's * infrastructure failures (pre-aborted signal, unusable workdir, missing * shell) — is translated into the same taxonomy: a pre-aborted signal becomes * `SEARCH_ABORTED`, everything else `SEARCH_FAILED`, with the original as * `cause`. * * @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 { const cwd = exec.agent?.session.header.cwd const spec = ctx.bash.resolve({ command, stdoutMaxBytes: rawOutputMaxBytes, ...cwd !== undefined ? { workdir: cwd } : {}, signal: exec.signal, }) let result: BashRunResult try { result = await ctx.bash.run(spec) } catch (error: unknown) { // The seam contract: run() REJECTS only for infrastructure failures — a // pre-aborted signal, an unusable workdir, a missing shell. Translate them // so these failures stay machine-routable under the SEARCH_* taxonomy. if (spec.signal?.aborted === true) { throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED', { cause: error }) } throw new SearchError(`${toolName} could not start its search command (unusable working directory or missing shell)`, 'SEARCH_FAILED', { cause: error }) } 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 = 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 } /** One parsed match: the file, the 1-based line number, and the (possibly previewed) line text. */ export interface GrepMatch { path: string lineNumber: number line: string } /** * Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and * mark the cut. The cap is a per-line budget fact; the complete line stays in * the searched file for `read`. * * @param line - the matched line text (trailing newline already stripped). * @param maxBytes - the preview budget in bytes. * @returns the preview, suffixed with ` (line truncated)` when bytes were cut. */ export function previewLine(line: string, maxBytes: number): string { const retainer = new TextRetainer({ kind: 'head', maxBytes }) retainer.push(line) const kept = retainer.finish() return kept.truncated ? `${kept.text} (line truncated)` : kept.text } /** * Apply the shared inline cap to a canonical `grep` match list: preview each * retained line to `maxLineBytes` and keep the first `maxMatches`. The single * retention pass both the model-facing render ({@link module:@deepseek-ai/dsh-tool-fs-search/grep} * `formatGrepOutput`) and the search-card projection * ({@link module:@deepseek-ai/dsh-tool-fs-search/presentation} `grepSearchMeta`) * consume, so text and card never disagree about which matches survived. * * @param matches - every match the search parsed (the canonical value's matches). * @param maxMatches - the inline match cap (the `grepMaxMatches` config). * @param maxLineBytes - the per-matched-line preview budget in bytes. * @returns the retention outcome over the previewed matches. */ export function retainGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number): RetainedItems { const retainer = new ItemRetainer({ kind: 'head', maxItems: maxMatches }) for (const match of matches) retainer.push({ ...match, line: previewLine(match.line, maxLineBytes) }) return retainer.finish() } /** * Apply the shared inline cap to a canonical `glob` path list: keep the first * `maxResults`. The single retention pass both the model-facing render and the * search-card projection consume. * * @param paths - every path the search discovered (the canonical value's paths). * @param maxResults - the inline path cap (the `globMaxResults` config). * @returns the retention outcome over the paths. */ export function retainGlobPaths(paths: string[], maxResults: number): RetainedItems { const retainer = new ItemRetainer({ kind: 'head', maxItems: maxResults }) for (const path of paths) retainer.push(path) return retainer.finish() } /** * Best-effort save of one COMPLETE formatted search result through * `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 * 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; `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 reference, or `undefined` when the result could not be saved. */ export async function trySaveFormattedResult( ctx: Context, exec: ToolExecution, suggestedName: string, content: string, ): Promise { 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 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 = { owner: { sessionId }, source: { toolName: exec.name, callId: exec.callId, label: 'result' }, suggestedName, content, } try { 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. ctx.logger.warn(`tool-fs-search: saveText failed for ${exec.name}: ${String(error)}; complete result not saved`) return undefined } }