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

@@ -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 } : {},
}