refactor(fs): minimize and cap search card meta; keep TUI byte-identical

Address the review of the search render card:

- The search result view carries no `content`: it was a no-op for every
  consumer and serialized the whole search text twice. A UI without a search
  card falls back to the raw tool/result content; the TUI stays byte-identical
  to the pre-search-card generic fallback.
- Bound the serialized presentationMeta with a configurable searchMetaMaxBytes
  (default 64 KiB): the inline item cap does not bound bytes, and spill-policy
  only shrinks content, never meta. capMetaBytes drops trailing groups/paths.
- Share one retention pass (retainGrepMatches/retainGlobPaths in search-core)
  between the model-facing render and the meta projection; remove the second
  cap/preview implementation and the presentation<->grep module cycle by
  moving GrepMatch/previewLine to search-core.
- Rename the result-view discriminant kind -> shape so it no longer collides
  with GenericCallView.kind (ToolCallKind, whose values include 'search').
- Narrow the entry export surface to consumed symbols.
- Sync the three bilingual ToolResultView doc pairs and the Agent Note pair;
  document the deliberate empty-card acceptance vs diffsFromMeta.
- Regenerate config/tool/cordis catalogs for the new config field.
This commit is contained in:
Chinesezjc
2026-07-30 21:57:49 +08:00
parent 74060dfb86
commit 7b6f33f872
23 changed files with 403 additions and 228 deletions

View File

@@ -12,12 +12,11 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
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'
import { retainGlobPaths, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { globSearchMeta, searchViewFromMeta } from './presentation.ts'
import { singleQuote } from './shell-quote.ts'
import { acceptedSurfaceValue } from './surface.ts'
@@ -44,6 +43,8 @@ export const GLOB_VCS_EXCLUDES: readonly string[] = ['.git', '.svn', '.hg', '.bz
export interface GlobToolCaps {
/** Max paths retained inline; later paths go to the formatted spill file. */
maxResults: number
/** Max bytes of serialized `presentationMeta`; trailing paths drop past it. */
maxMetaBytes: number
/** Cap on the complete raw `rg` stdout the tool will parse. */
rawOutputMaxBytes: number
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
@@ -118,12 +119,10 @@ export function formatGlobOutput(retained: RetainedItems<string>, spillRef: Spil
return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})`
}
/** Retain and format one canonical path list for the Native surface. */
function renderGlobPaths(paths: string[], maxResults: number, spillRef?: SpillRef): string {
if (paths.length === 0) return 'No files found'
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: maxResults })
for (const path of paths) retainer.push(path)
return formatGlobOutput(retainer.finish(), spillRef)
/** Format one already-retained path list for the Native surface. */
function formatRetainedGlob(retained: RetainedItems<string>, spillRef?: SpillRef): string {
if (retained.seen === 0) return 'No files found'
return formatGlobOutput(retained, spillRef)
}
/**
@@ -139,10 +138,10 @@ export function presentGlobCall(args: { pattern: string; path?: string }): Gener
/**
* Completed-call presentation: the search card projected from the result's
* `presentationMeta` (the discovered path list, with the truncation signal), with
* the model-facing result text attached as `content` for a UI without a search
* card. Malformed or absent metadata (an obsolete or hand-edited replayed log)
* falls back to the generic card.
* `presentationMeta` (the discovered path list, with the truncation signal). A UI
* without a search card falls back to the raw `tool/result` content, so the view
* carries no result text of its own. Malformed or absent metadata (an obsolete or
* hand-edited replayed log) falls back to the generic card.
*
* @param _args - the raw tool arguments; unused, the view derives from the result.
* @param result - the final model-facing tool result carrying the projected metadata.
@@ -151,8 +150,8 @@ export function presentGlobCall(args: { pattern: string; path?: string }): Gener
export function presentGlobResult(_args: { pattern: string; path?: string }, result: ToolResult): SearchResultView | undefined {
if (result.isError) return undefined
const view = searchViewFromMeta(result.meta)
if (view === undefined || view.kind !== 'paths') return undefined
return { ...view, content: result.content }
if (view === undefined || view.shape !== 'paths') return undefined
return view
}
/**
@@ -187,8 +186,8 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
paths: { type: 'array', required: true, items: { type: 'string' } },
},
},
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults) }],
presentationMeta: (_args, value) => globSearchMeta(value.paths, caps.maxResults),
render: (_args, value) => [{ type: 'text', text: formatRetainedGlob(retainGlobPaths(value.paths, caps.maxResults)) }],
presentationMeta: (_args, value) => globSearchMeta(retainGlobPaths(value.paths, caps.maxResults), caps.maxMetaBytes),
},
async execute(args, exec) {
const input = parseGlobArgs(args)
@@ -217,7 +216,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
const spillRef = await trySaveFormattedResult(ctx, exec, 'glob-results.txt', paths.join('\n'))
return {
kind: 'accept',
content: [{ type: 'text', text: renderGlobPaths(paths, caps.maxResults, spillRef) }],
content: [{ type: 'text', text: formatRetainedGlob(retainGlobPaths(paths, caps.maxResults), spillRef) }],
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
}
})

View File

@@ -13,12 +13,12 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
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'
import type { GrepMatch } from './search-core.ts'
import { SearchError, previewLine, retainGrepMatches, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { grepSearchMeta, searchViewFromMeta } from './presentation.ts'
import { singleQuote } from './shell-quote.ts'
import { acceptedSurfaceValue } from './surface.ts'
@@ -42,6 +42,8 @@ export interface GrepToolCaps {
maxMatches: number
/** Max bytes retained per matched-line preview. */
maxLineBytes: number
/** Max bytes of serialized `presentationMeta`; trailing file groups drop past it. */
maxMetaBytes: number
/** Cap on the complete raw `rg` stdout the tool will parse. */
rawOutputMaxBytes: number
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
@@ -55,13 +57,6 @@ export interface GrepInput {
include?: string
}
/** 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
}
/**
* Reject an `include` that is not ONE positive glob filter: blank strings,
* negated patterns (`!…`), and comma-separated lists. A comma inside a brace
@@ -178,22 +173,6 @@ export function parseGrepMatches(stdout: string): GrepMatch[] {
return matches
}
/**
* 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
}
/** `match` / `matches` for a count. */
function matchNoun(count: number): string {
return count === 1 ? 'match' : 'matches'
@@ -242,18 +221,10 @@ export function formatGrepOutput(retained: RetainedItems<GrepMatch>, spillRef: S
return `${header}\n\n${body}\n\n(${recovery})`
}
/** Apply the Native per-line preview budget without changing the canonical matches. */
function previewGrepMatches(matches: GrepMatch[], maxLineBytes: number): GrepMatch[] {
return matches.map(match => ({ ...match, line: previewLine(match.line, maxLineBytes) }))
}
/** Retain and format one canonical match list for the Native surface. */
function renderGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number, spillRef?: SpillRef): string {
if (matches.length === 0) return 'No matches found'
const previewed = previewGrepMatches(matches, maxLineBytes)
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: maxMatches })
for (const match of previewed) retainer.push(match)
return formatGrepOutput(retainer.finish(), spillRef)
/** Format one already-retained match list for the Native surface. */
function formatRetainedGrep(retained: RetainedItems<GrepMatch>, spillRef?: SpillRef): string {
if (retained.seen === 0) return 'No matches found'
return formatGrepOutput(retained, spillRef)
}
/**
@@ -271,10 +242,10 @@ export function presentGrepCall(args: { pattern: string; path?: string; include?
/**
* Completed-call presentation: the search card projected from the result's
* `presentationMeta` (matches grouped by file, with the truncation signal), with
* the model-facing result text attached as `content` for a UI without a search
* card. Malformed or absent metadata (an obsolete or hand-edited replayed log)
* falls back to the generic card.
* `presentationMeta` (matches grouped by file, with the truncation signal). A UI
* without a search card falls back to the raw `tool/result` content, so the view
* carries no result text of its own. Malformed or absent metadata (an obsolete or
* hand-edited replayed log) falls back to the generic card.
*
* @param _args - the raw tool arguments; unused, the view derives from the result.
* @param result - the final model-facing tool result carrying the projected metadata.
@@ -286,8 +257,8 @@ export function presentGrepResult(
): SearchResultView | undefined {
if (result.isError) return undefined
const view = searchViewFromMeta(result.meta)
if (view === undefined || view.kind !== 'matches') return undefined
return { ...view, content: result.content }
if (view === undefined || view.shape !== 'matches') return undefined
return view
}
/**
@@ -337,9 +308,10 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
},
render: (_args, value) => [{
type: 'text',
text: renderGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes),
text: formatRetainedGrep(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes)),
}],
presentationMeta: (_args, value) => grepSearchMeta(value.matches, caps.maxMatches, caps.maxLineBytes),
presentationMeta: (_args, value) =>
grepSearchMeta(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes), caps.maxMetaBytes),
},
async execute(args, exec) {
const input = parseGrepArgs(args)
@@ -368,17 +340,20 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
if (value === undefined) return decision
const matches = value.matches
if (matches.length <= caps.maxMatches) return decision
// The spill artifact holds the COMPLETE result: preview each line, but keep
// every match (no inline cap), so the recovery file is the full search.
const previewedAll = matches.map(match => ({ ...match, line: previewLine(match.line, caps.maxLineBytes) }))
const spillRef = await trySaveFormattedResult(
ctx,
exec,
'grep-results.txt',
`Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewGrepMatches(matches, caps.maxLineBytes))}`,
`Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewedAll)}`,
)
return {
kind: 'accept',
content: [{
type: 'text',
text: renderGrepMatches(matches, caps.maxMatches, caps.maxLineBytes, spillRef),
text: formatRetainedGrep(retainGrepMatches(matches, caps.maxMatches, caps.maxLineBytes), spillRef),
}],
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
}

View File

@@ -31,7 +31,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts'
import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts'
import { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
import { RAW_OUTPUT_MAX_BYTES, SEARCH_META_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, presentGlobResult } from './glob.ts'
export type { GlobInput, GlobToolCaps } from './glob.ts'
@@ -46,13 +46,19 @@ export {
parseGrepMatches,
presentGrepCall,
presentGrepResult,
previewLine,
} from './grep.ts'
export type { GrepInput, GrepMatch, GrepToolCaps } from './grep.ts'
export { globSearchMeta, grepSearchMeta, groupMatchesByFile, searchViewFromMeta } from './presentation.ts'
export type { SearchMeta } from './presentation.ts'
export { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS, SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
export type { RipgrepRun, SearchErrorCode } from './search-core.ts'
export type { GrepInput, GrepToolCaps } from './grep.ts'
export {
RAW_OUTPUT_MAX_BYTES,
SEARCH_META_MAX_BYTES,
SEARCH_TIMEOUT_MS,
SearchError,
previewLine,
runRipgrep,
toWorkdirRelative,
trySaveFormattedResult,
} from './search-core.ts'
export type { GrepMatch, RipgrepRun, SearchErrorCode } from './search-core.ts'
export { singleQuote } from './shell-quote.ts'
/** Cordis plugin name used by loader diagnostics. */
@@ -69,6 +75,8 @@ export interface Config {
grepMaxMatches?: number
/** Max bytes retained for one matched-line preview (the cut preserves UTF-8 boundaries). */
grepMaxLineBytes?: number
/** Max bytes of one search's serialized `presentationMeta`; trailing groups/paths drop past it so the persisted card stays bounded. */
searchMetaMaxBytes?: number
/** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */
rawOutputMaxBytes?: number
/** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */
@@ -79,6 +87,7 @@ export const Config: z<Config> = z.object({
globMaxResults: z.number().default(GLOB_MAX_RESULTS),
grepMaxMatches: z.number().default(GREP_MAX_MATCHES),
grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES),
searchMetaMaxBytes: z.number().default(SEARCH_META_MAX_BYTES),
rawOutputMaxBytes: z.number().default(RAW_OUTPUT_MAX_BYTES),
timeoutMs: z.number().default(SEARCH_TIMEOUT_MS),
})
@@ -133,6 +142,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
assertPositiveInteger('globMaxResults', resolved.globMaxResults)
assertPositiveInteger('grepMaxMatches', resolved.grepMaxMatches)
assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes)
assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes)
assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes)
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
if (!await ripgrepAvailable(ctx)) {
@@ -141,12 +151,14 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
}
applyGlobTool(ctx, {
maxResults: resolved.globMaxResults,
maxMetaBytes: resolved.searchMetaMaxBytes,
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
timeoutMs: resolved.timeoutMs,
})
applyGrepTool(ctx, {
maxMatches: resolved.grepMaxMatches,
maxLineBytes: resolved.grepMaxLineBytes,
maxMetaBytes: resolved.searchMetaMaxBytes,
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
timeoutMs: resolved.timeoutMs,
})

View File

@@ -1,7 +1,7 @@
/**
* Result-time search-card presentation for `grep` and `glob`. Both tools land on
* one `card: 'search'` render intent ({@link SearchResultView}) with two
* `kind`-discriminated shapes: `grep` projects its matches grouped by file
* `shape`-discriminated variants: `grep` projects its matches grouped by file
* ({@link SearchMatchesResultView}), `glob` projects a flat path list
* ({@link SearchPathsResultView}). This module owns the value→`presentationMeta`
* projection each tool declares and the defensive `meta`→view narrowing each
@@ -9,11 +9,19 @@
*
* The canonical value never crosses the wire — only the model-facing render text
* and this JSON `meta` do — so the structured shape a UI renders MUST ride in
* `meta`. Each projection applies the SAME inline cap the model-facing render
* applies ({@link module:@deepseek-ai/dsh-tool-fs-search/grep} `grepMaxMatches`,
* {@link module:@deepseek-ai/dsh-tool-fs-search/glob} `globMaxResults`) and reports
* `total` (every result found) and `truncated`, so a UI never presents a capped
* result as complete.
* `meta`. Each projection consumes the SAME retained matches/paths the
* model-facing render consumes ({@link module:@deepseek-ai/dsh-tool-fs-search/search-core}
* `retainGrepMatches`/`retainGlobPaths`), so text and card agree about which
* results survived the inline cap, and reports `total` (every result found) and
* `truncated`, so a UI never presents a capped result as complete.
*
* A second, independent cap bounds the JSON `meta` itself: the retained matches
* of a broad search (hundreds of long lines) can still serialize to hundreds of
* kilobytes, and `meta` is persisted with the session log and re-sent on every
* request. {@link capMetaBytes} drops trailing groups/paths until the serialized
* `meta` fits `maxMetaBytes` and marks the result `truncated`; a deployment's
* final output budget (`dsh-spill-policy`) only shrinks `content`, never `meta`,
* so this projection owns keeping `meta` bounded.
*
* @module @deepseek-ai/dsh-tool-fs-search/presentation
*/
@@ -23,9 +31,8 @@ import type {
SearchLineMatch,
SearchResultView,
} from '@deepseek-ai/dsh-tools'
import { ItemRetainer } from '@deepseek-ai/dsh-retention'
import type { GrepMatch } from './grep.ts'
import { previewLine } from './grep.ts'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { GrepMatch } from './search-core.ts'
/**
* The `grep`/`glob` tools' private `tool/result` `meta` payload: the capped,
@@ -42,8 +49,8 @@ import { previewLine } from './grep.ts'
* back as a {@link SearchResultView}.
*/
export type SearchMeta =
| { kind: 'matches'; files: MetaFileMatches[]; truncated: boolean; total: number }
| { kind: 'paths'; paths: string[]; truncated: boolean; total: number }
| { shape: 'matches'; files: MetaFileMatches[]; truncated: boolean; total: number }
| { shape: 'paths'; paths: string[]; truncated: boolean; total: number }
/** One matched line in {@link SearchMeta} (the JSON-assignable form of {@link SearchLineMatch}). */
type MetaLineMatch = { lineNumber: number; line: string }
@@ -72,38 +79,73 @@ export function groupMatchesByFile(matches: GrepMatch[]): MetaFileMatches[] {
return Array.from(byFile, ([path, fileMatches]) => ({ path, matches: fileMatches }))
}
/**
* Project the canonical `grep` matches into {@link SearchMeta} for the search
* card. Applies the per-line preview budget and the inline match cap exactly as
* the model-facing render does, groups the retained matches by file, and reports
* `total` (every parsed match) and `truncated`.
*
* @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 `matches`-shaped search metadata.
*/
export function grepSearchMeta(matches: GrepMatch[], maxMatches: number, maxLineBytes: number): SearchMeta {
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: maxMatches })
for (const match of matches) retainer.push({ ...match, line: previewLine(match.line, maxLineBytes) })
const retained = retainer.finish()
return { kind: 'matches', files: groupMatchesByFile(retained.items), truncated: retained.truncated, total: retained.seen }
/** The serialized UTF-8 byte size of one meta payload (the size persisted and re-sent). */
function metaBytes(meta: SearchMeta): number {
return Buffer.byteLength(JSON.stringify(meta), 'utf8')
}
/**
* Project the canonical `glob` paths into {@link SearchMeta} for the search card.
* Applies the inline path cap exactly as the model-facing render does and reports
* `total` (every discovered path) and `truncated`.
* Drop trailing top-level items (file groups or paths) until the serialized meta
* fits `maxMetaBytes`, marking the result `truncated` when anything was dropped.
* `total` is preserved (it counts what the search found, not what meta retains).
* A single item too large to fit on its own is kept: the invariant is a bounded
* payload wherever droppable, never an empty card that hides a real result.
*
* @param paths - every path the search discovered (the canonical value's paths).
* @param maxResults - the inline path cap (the `globMaxResults` config).
* @param meta - the projected meta, already capped to the inline item count.
* @param maxMetaBytes - the serialized-meta byte budget.
* @returns the same meta when it fits, else a byte-bounded copy marked `truncated`.
*/
function capMetaBytes(meta: SearchMeta, maxMetaBytes: number): SearchMeta {
if (metaBytes(meta) <= maxMetaBytes) return meta
if (meta.shape === 'matches') {
const files = [...meta.files]
while (files.length > 1 && metaBytes({ ...meta, files, truncated: true }) > maxMetaBytes) files.pop()
return { ...meta, files, truncated: true }
}
const paths = [...meta.paths]
while (paths.length > 1 && metaBytes({ ...meta, paths, truncated: true }) > maxMetaBytes) paths.pop()
return { ...meta, paths, truncated: true }
}
/**
* Project the retained `grep` matches into {@link SearchMeta} for the search
* card. Consumes the same {@link RetainedItems} the model-facing render consumes
* (preview budget and inline match cap already applied), groups the retained
* matches by file, reports `total` (every parsed match) and `truncated`, then
* bounds the serialized meta to `maxMetaBytes`.
*
* @param retained - the retention outcome over every parsed match (previewed, capped).
* @param maxMetaBytes - the serialized-meta byte budget.
* @returns the `matches`-shaped search metadata.
*/
export function grepSearchMeta(retained: RetainedItems<GrepMatch>, maxMetaBytes: number): SearchMeta {
const meta: SearchMeta = {
shape: 'matches',
files: groupMatchesByFile(retained.items),
truncated: retained.truncated,
total: retained.seen,
}
return capMetaBytes(meta, maxMetaBytes)
}
/**
* Project the retained `glob` paths into {@link SearchMeta} for the search card.
* Consumes the same {@link RetainedItems} the model-facing render consumes (inline
* path cap already applied), reports `total` (every discovered path) and
* `truncated`, then bounds the serialized meta to `maxMetaBytes`.
*
* @param retained - the retention outcome over every discovered path (capped).
* @param maxMetaBytes - the serialized-meta byte budget.
* @returns the `paths`-shaped search metadata.
*/
export function globSearchMeta(paths: string[], maxResults: number): SearchMeta {
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: maxResults })
for (const path of paths) retainer.push(path)
const retained = retainer.finish()
return { kind: 'paths', paths: retained.items, truncated: retained.truncated, total: retained.seen }
export function globSearchMeta(retained: RetainedItems<string>, maxMetaBytes: number): SearchMeta {
const meta: SearchMeta = {
shape: 'paths',
paths: retained.items,
truncated: retained.truncated,
total: retained.seen,
}
return capMetaBytes(meta, maxMetaBytes)
}
/** Whether `value` is a valid {@link SearchLineMatch} (defensive narrowing from opaque `meta`). */
@@ -124,8 +166,13 @@ function isSearchFileMatches(value: unknown): value is SearchFileMatches {
* Narrow opaque live or replayed result metadata to a {@link SearchResultView}.
* Malformed metadata returns `undefined` so `presentResult` can fall back to the
* generic card instead of throwing during replay of an older or hand-edited log.
* The returned view carries no `content`; the caller attaches the model-facing
* result text so a UI without a search card renders it as text.
* The view carries no result text: a UI without a search card falls back to the
* raw `tool/result` content.
*
* A zero-result meta (`files: []` / `paths: []`) narrows to a valid empty card —
* unlike the mirrored `diffsFromMeta`, which rejects empty diffs, because a
* zero-match grep is a legitimate result a UI shows as "no matches", not an
* absent projection.
*
* @param meta - result metadata (the {@link SearchMeta} the tool projected).
* @returns the search view, or `undefined` for absent or malformed metadata.
@@ -135,15 +182,15 @@ export function searchViewFromMeta(meta: unknown): SearchResultView | undefined
const record = meta as Record<string, unknown>
const { truncated, total } = record
if (typeof truncated !== 'boolean' || typeof total !== 'number') return undefined
if (record.kind === 'matches') {
if (record.shape === 'matches') {
const { files } = record
if (!Array.isArray(files) || !files.every(isSearchFileMatches)) return undefined
return { card: 'search', kind: 'matches', files: files, truncated, total }
return { card: 'search', shape: 'matches', files: files, truncated, total }
}
if (record.kind === 'paths') {
if (record.shape === 'paths') {
const { paths } = record
if (!Array.isArray(paths) || !paths.every((path): path is string => typeof path === 'string')) return undefined
return { card: 'search', kind: 'paths', paths, truncated, total }
return { card: 'search', shape: 'paths', paths, truncated, total }
}
return undefined
}

View File

@@ -19,6 +19,8 @@
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'
@@ -36,6 +38,18 @@ export const RAW_OUTPUT_MAX_BYTES = 20_000_000
*/
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`
@@ -212,6 +226,63 @@ export function toWorkdirRelative(path: string, workdir: string): string {
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<GrepMatch> {
const retainer = new ItemRetainer<GrepMatch>({ 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<string> {
const retainer = new ItemRetainer<string>({ 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

View File

@@ -2,9 +2,10 @@
* Unit tests for the search-card presentation layer (`src/presentation.ts`): the
* canonical value → `presentationMeta` projections (`grepSearchMeta`,
* `globSearchMeta`, `groupMatchesByFile`) and the defensive `meta` → view
* narrowing (`searchViewFromMeta`). These pin the by-file grouping, the inline
* cap and `truncated`/`total` honesty, and the malformed-metadata fallback a
* replayed or hand-edited log can deliver.
* narrowing (`searchViewFromMeta`). These pin the by-file grouping, the
* `truncated`/`total` honesty over already-retained input, the serialized-meta
* byte cap, and the malformed-metadata fallback a replayed or hand-edited log can
* deliver.
*/
import { describe, expect, it } from 'vitest'
@@ -15,10 +16,14 @@ import {
groupMatchesByFile,
searchViewFromMeta,
} from '../src/presentation.ts'
import type { GrepMatch } from '../src/grep.ts'
import type { GrepMatch } from '../src/search-core.ts'
import { retainGlobPaths, retainGrepMatches } from '../src/search-core.ts'
const match = (path: string, lineNumber: number, line: string): GrepMatch => ({ path, lineNumber, line })
/** A byte cap large enough that no test payload here is meta-capped. */
const WIDE = 1_000_000
describe('groupMatchesByFile', () => {
it('groups matches by first-seen file order, keeping line/lineNumber only', () => {
expect(groupMatchesByFile([
@@ -38,38 +43,73 @@ describe('groupMatchesByFile', () => {
describe('grepSearchMeta', () => {
it('projects grouped matches with total and a false truncation flag within the cap', () => {
const meta = grepSearchMeta([match('a.ts', 1, 'one'), match('a.ts', 2, 'two')], 10, 2000)
const meta = grepSearchMeta(retainGrepMatches([match('a.ts', 1, 'one'), match('a.ts', 2, 'two')], 10, 2000), WIDE)
expect(meta).toEqual({
kind: 'matches',
shape: 'matches',
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
truncated: false,
total: 2,
})
})
it('caps the retained matches and reports the pre-cap total when truncated', () => {
const meta = grepSearchMeta([match('a.ts', 1, 'one'), match('a.ts', 2, 'two'), match('b.ts', 3, 'three')], 2, 2000)
it('reports the pre-cap total and truncation from the shared retention pass', () => {
const meta = grepSearchMeta(retainGrepMatches([match('a.ts', 1, 'one'), match('a.ts', 2, 'two'), match('b.ts', 3, 'three')], 2, 2000), WIDE)
expect(meta).toEqual({
kind: 'matches',
shape: 'matches',
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
truncated: true,
total: 3,
})
})
it('applies the per-line preview budget (UTF-8 boundary) to the projected line', () => {
const meta = grepSearchMeta([match('a.txt', 1, 'aéaéaéaé')], 10, 7)
expect(meta).toMatchObject({ kind: 'matches', files: [{ path: 'a.txt', matches: [{ lineNumber: 1, line: 'aéaéa (line truncated)' }] }] })
it('carries the per-line preview budget (UTF-8 boundary) the retention pass applied', () => {
const meta = grepSearchMeta(retainGrepMatches([match('a.txt', 1, 'aéaéaéaé')], 10, 7), WIDE)
expect(meta).toMatchObject({ shape: 'matches', files: [{ path: 'a.txt', matches: [{ lineNumber: 1, line: 'aéaéa (line truncated)' }] }] })
})
it('drops trailing file groups until the serialized meta fits the byte cap, marking it truncated', () => {
const retained = retainGrepMatches(
[match('a.ts', 1, 'x'.repeat(60)), match('b.ts', 2, 'y'.repeat(60)), match('c.ts', 3, 'z'.repeat(60))],
10,
2000,
)
// One 60-byte group serializes to ~110 bytes; a 260-byte cap holds two, not three.
const meta = grepSearchMeta(retained, 260)
expect(meta.shape).toBe('matches')
if (meta.shape !== 'matches') throw new Error('unreachable')
expect(meta.truncated).toBe(true)
expect(meta.total).toBe(3)
expect(meta.files.length).toBeLessThan(3)
expect(Buffer.byteLength(JSON.stringify(meta), 'utf8')).toBeLessThanOrEqual(260)
})
it('keeps a single oversized group rather than emit an empty card', () => {
const meta = grepSearchMeta(retainGrepMatches([match('a.ts', 1, 'x'.repeat(500))], 10, 2000), 50)
expect(meta.shape).toBe('matches')
if (meta.shape !== 'matches') throw new Error('unreachable')
expect(meta.files).toHaveLength(1)
expect(meta.truncated).toBe(true)
})
})
describe('globSearchMeta', () => {
it('projects the path list with total and a false truncation flag within the cap', () => {
expect(globSearchMeta(['a.ts', 'b.ts'], 10)).toEqual({ kind: 'paths', paths: ['a.ts', 'b.ts'], truncated: false, total: 2 })
expect(globSearchMeta(retainGlobPaths(['a.ts', 'b.ts'], 10), WIDE)).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: false, total: 2 })
})
it('caps the retained paths and reports the pre-cap total when truncated', () => {
expect(globSearchMeta(['a.ts', 'b.ts', 'c.ts'], 2)).toEqual({ kind: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 })
it('reports the pre-cap total and truncation from the shared retention pass', () => {
expect(globSearchMeta(retainGlobPaths(['a.ts', 'b.ts', 'c.ts'], 2), WIDE)).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 })
})
it('drops trailing paths until the serialized meta fits the byte cap, marking it truncated', () => {
const retained = retainGlobPaths([`${'a'.repeat(100)}.ts`, `${'b'.repeat(100)}.ts`, `${'c'.repeat(100)}.ts`], 10)
const meta = globSearchMeta(retained, 180)
expect(meta.shape).toBe('paths')
if (meta.shape !== 'paths') throw new Error('unreachable')
expect(meta.truncated).toBe(true)
expect(meta.total).toBe(3)
expect(meta.paths.length).toBeLessThan(3)
expect(Buffer.byteLength(JSON.stringify(meta), 'utf8')).toBeLessThanOrEqual(180)
})
})
@@ -80,15 +120,22 @@ describe('searchViewFromMeta (defensive narrowing)', () => {
const m = (value: unknown): JsonValue | undefined => value as JsonValue | undefined
it('narrows a well-formed matches payload into a matches view', () => {
const meta = { kind: 'matches', files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }] }], truncated: true, total: 5 }
const meta = { shape: 'matches', files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }] }], truncated: true, total: 5 }
expect(searchViewFromMeta(m(meta))).toEqual({ card: 'search', ...meta })
})
it('narrows a well-formed paths payload into a paths view', () => {
const meta = { kind: 'paths', paths: ['a.ts', 'b.ts'], truncated: false, total: 2 }
const meta = { shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: false, total: 2 }
expect(searchViewFromMeta(m(meta))).toEqual({ card: 'search', ...meta })
})
it('narrows a zero-result payload into a valid empty card (not a rejected projection)', () => {
expect(searchViewFromMeta(m({ shape: 'matches', files: [], truncated: false, total: 0 })))
.toEqual({ card: 'search', shape: 'matches', files: [], truncated: false, total: 0 })
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: false, total: 0 })))
.toEqual({ card: 'search', shape: 'paths', paths: [], truncated: false, total: 0 })
})
it('rejects undefined / non-object / array meta', () => {
expect(searchViewFromMeta(undefined)).toBeUndefined()
expect(searchViewFromMeta(null)).toBeUndefined()
@@ -97,19 +144,19 @@ describe('searchViewFromMeta (defensive narrowing)', () => {
})
it('rejects a payload with a missing / mistyped truncated or total field', () => {
expect(searchViewFromMeta(m({ kind: 'paths', paths: [], total: 0 }))).toBeUndefined()
expect(searchViewFromMeta(m({ kind: 'paths', paths: [], truncated: 'no', total: 0 }))).toBeUndefined()
expect(searchViewFromMeta(m({ kind: 'paths', paths: [], truncated: false }))).toBeUndefined()
expect(searchViewFromMeta(m({ kind: 'paths', paths: [], truncated: false, total: '0' }))).toBeUndefined()
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], total: 0 }))).toBeUndefined()
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: 'no', total: 0 }))).toBeUndefined()
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: false }))).toBeUndefined()
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: false, total: '0' }))).toBeUndefined()
})
it('rejects an unknown or missing kind discriminant', () => {
expect(searchViewFromMeta(m({ kind: 'other', truncated: false, total: 0 }))).toBeUndefined()
it('rejects an unknown or missing shape discriminant', () => {
expect(searchViewFromMeta(m({ shape: 'other', truncated: false, total: 0 }))).toBeUndefined()
expect(searchViewFromMeta(m({ truncated: false, total: 0 }))).toBeUndefined()
})
it('rejects a matches payload with a malformed files array', () => {
const base = { kind: 'matches', truncated: false, total: 1 }
const base = { shape: 'matches', truncated: false, total: 1 }
expect(searchViewFromMeta(m({ ...base, files: 'x' }))).toBeUndefined()
expect(searchViewFromMeta(m({ ...base, files: [null] }))).toBeUndefined()
expect(searchViewFromMeta(m({ ...base, files: ['x'] }))).toBeUndefined()
@@ -122,7 +169,7 @@ describe('searchViewFromMeta (defensive narrowing)', () => {
})
it('rejects a paths payload with a non-array or non-string-element paths field', () => {
const base = { kind: 'paths', truncated: false, total: 1 }
const base = { shape: 'paths', truncated: false, total: 1 }
expect(searchViewFromMeta(m({ ...base, paths: 'x' }))).toBeUndefined()
expect(searchViewFromMeta(m({ ...base, paths: [1] }))).toBeUndefined()
})

View File

@@ -817,7 +817,7 @@ describe('presentation', () => {
if (result.isError) throw new Error('expected grep success')
// The presentationMeta projection rides the result meta (a surface call).
expect(result.meta).toEqual({
kind: 'matches',
shape: 'matches',
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
truncated: true,
total: 3,
@@ -825,11 +825,10 @@ describe('presentation', () => {
const view = presentGrepResult({ pattern: 'e' }, result)
expect(view).toEqual({
card: 'search',
kind: 'matches',
shape: 'matches',
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
truncated: true,
total: 3,
content: result.content,
})
})
@@ -838,9 +837,9 @@ describe('presentation', () => {
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\n')
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
if (result.isError) throw new Error('expected glob success')
expect(result.meta).toEqual({ kind: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 })
expect(result.meta).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 })
const view = presentGlobResult({ pattern: '*.ts' }, result)
expect(view).toEqual({ card: 'search', kind: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3, content: result.content })
expect(view).toEqual({ card: 'search', shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 })
})
it('nested Code dispatch computes no meta, so presentResult falls back to the generic card', async () => {
@@ -860,15 +859,15 @@ describe('presentation', () => {
expect(presentGrepResult({ pattern: 'x' }, errorResult)).toBeUndefined()
expect(presentGlobResult({ pattern: '*' }, errorResult)).toBeUndefined()
// A grep result carrying a paths-shaped meta (and vice versa) is not this
// tool's shape: each presenter narrows to its own kind and otherwise falls back.
const pathsResult = { content: [], isError: false, meta: { kind: 'paths', paths: ['a.ts'], truncated: false, total: 1 } }
const matchesResult = { content: [], isError: false, meta: { kind: 'matches', files: [], truncated: false, total: 0 } }
// tool's shape: each presenter narrows to its own shape and otherwise falls back.
const pathsResult = { content: [], isError: false, meta: { shape: 'paths', paths: ['a.ts'], truncated: false, total: 1 } }
const matchesResult = { content: [], isError: false, meta: { shape: 'matches', files: [], truncated: false, total: 0 } }
expect(presentGrepResult({ pattern: 'x' }, pathsResult)).toBeUndefined()
expect(presentGlobResult({ pattern: '*' }, matchesResult)).toBeUndefined()
})
it('presentResult falls back to the generic card on malformed replayed meta', () => {
const malformed = { content: [], isError: false, meta: { kind: 'matches', files: 'nope', truncated: false, total: 0 } }
const malformed = { content: [], isError: false, meta: { shape: 'matches', files: 'nope', truncated: false, total: 0 } }
expect(presentGrepResult({ pattern: 'x' }, malformed)).toBeUndefined()
expect(presentGlobResult({ pattern: '*' }, { content: [], isError: false, meta: 42 })).toBeUndefined()
})