fix(fs-search): make glob sampling an explicit choice
This commit is contained in:
@@ -38,6 +38,8 @@ export const GLOB_VCS_EXCLUDES: readonly string[] = ['.git', '.svn', '.hg', '.bz
|
||||
|
||||
/** Resolved glob-tool caps — plugin config after defaulting (see `Config` in index.ts). */
|
||||
export interface GlobToolCaps {
|
||||
/** Whether over-cap pages are sampled across top-level entries instead of taking the modification-time head. */
|
||||
sampleOverCapGlobResults: boolean
|
||||
/** Max paths retained inline; later paths go to the formatted spill file. */
|
||||
maxResults: number
|
||||
/** Cap on the complete raw `rg` stdout the tool will parse. */
|
||||
@@ -183,24 +185,32 @@ export function sampleAcrossTopLevel(paths: readonly string[], maxItems: number,
|
||||
* @returns the model-facing text.
|
||||
*/
|
||||
export function formatGlobOutput(sample: GlobSample, seen: number, spillRef: SpillRef | undefined): string {
|
||||
const body = sample.items.join('\n')
|
||||
const recovery = spillRef !== undefined
|
||||
? `Full sorted result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`
|
||||
: 'The complete result could not be saved; narrow pattern or path to see more.'
|
||||
const basis = sample.total === seen
|
||||
? '.'
|
||||
: `, sampled across ${sample.shown} of the ${sample.total} top-level entries this pattern matched instead of taken in modification-time order.`
|
||||
+ (sample.shown < sample.total ? ' Narrow path to inspect a specific subtree.' : '')
|
||||
return `${body}\n\n(Showing ${sample.items.length} of ${seen} paths${basis} ${recovery})`
|
||||
return formatGlobPage(sample.items, seen, spillRef, basis)
|
||||
}
|
||||
|
||||
/** Format one bounded page and the recovery path for its complete sorted result. */
|
||||
function formatGlobPage(items: readonly string[], seen: number, spillRef: SpillRef | undefined, basis: string): string {
|
||||
const body = items.join('\n')
|
||||
const recovery = spillRef !== undefined
|
||||
? `Full sorted result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`
|
||||
: 'The complete result could not be saved; narrow pattern or path to see more.'
|
||||
return `${body}\n\n(Showing ${items.length} of ${seen} paths${basis} ${recovery})`
|
||||
}
|
||||
|
||||
/** Bound and format one canonical path list for the Native surface relative to its search root. */
|
||||
function renderGlobPaths(paths: string[], maxResults: number, root: string, spillRef?: SpillRef): string {
|
||||
function renderGlobPaths(paths: string[], caps: GlobToolCaps, root: string, spillRef?: SpillRef): string {
|
||||
if (paths.length === 0) return 'No files found'
|
||||
// A result that fits is shown whole, untouched: modification-time order is the
|
||||
// tool's contract, and over a complete result it is what answers age questions.
|
||||
if (paths.length <= maxResults) return paths.join('\n')
|
||||
return formatGlobOutput(sampleAcrossTopLevel(paths, maxResults, root), paths.length, spillRef)
|
||||
if (paths.length <= caps.maxResults) return paths.join('\n')
|
||||
if (!caps.sampleOverCapGlobResults) {
|
||||
return formatGlobPage(paths.slice(0, caps.maxResults), paths.length, spillRef, '.')
|
||||
}
|
||||
return formatGlobOutput(sampleAcrossTopLevel(paths, caps.maxResults, root), paths.length, spillRef)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,19 +232,24 @@ export function presentGlobCall(args: { pattern: string; path?: string }): Gener
|
||||
* @param caps - the deployment's resolved glob caps (plugin config after defaulting).
|
||||
*/
|
||||
export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
|
||||
const overCapGuidance = caps.sampleOverCapGlobResults
|
||||
? 'while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree.'
|
||||
: 'while a larger one keeps the modification-time-ordered head.'
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:glob',
|
||||
order: 103,
|
||||
text: 'Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. '
|
||||
+ 'Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level entries, '
|
||||
+ 'so it spans the tree instead of one subtree.',
|
||||
+ `Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, ${overCapGuidance}`,
|
||||
})
|
||||
|
||||
const overCapDescription = caps.sampleOverCapGlobResults
|
||||
? `a larger result instead returns ${caps.maxResults} paths sampled across top-level entries`
|
||||
: `a larger result returns the first ${caps.maxResults} paths in modification-time order`
|
||||
const tool = defineTool({
|
||||
name: 'glob',
|
||||
description: 'Find files whose paths match a glob pattern. Returns matching file paths — never directories — '
|
||||
+ 'including hidden and ignored files (VCS metadata directories are excluded). '
|
||||
+ `Up to ${caps.maxResults} paths come back in modification-time order; a larger result instead returns ${caps.maxResults} paths sampled across top-level entries, `
|
||||
+ `Up to ${caps.maxResults} paths come back in modification-time order; ${overCapDescription}, `
|
||||
+ 'says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.',
|
||||
parameters: {
|
||||
pattern: {
|
||||
@@ -255,7 +270,7 @@ 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, value.root) }],
|
||||
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps, value.root) }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const input = parseGlobArgs(args)
|
||||
@@ -284,7 +299,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, value.root, spillRef) }],
|
||||
content: [{ type: 'text', text: renderGlobPaths(paths, caps, value.root, spillRef) }],
|
||||
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -58,8 +58,10 @@ export const name = 'tool-fs-search'
|
||||
/** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */
|
||||
export const inject = ['tools', 'systemPrompt', 'bash']
|
||||
|
||||
/** Plugin config (all optional — `Config` supplies the defaults). */
|
||||
/** Plugin config; over-cap glob sampling is an explicit deployment choice and the remaining fields have defaults. */
|
||||
export interface Config {
|
||||
/** Whether an over-cap `glob` page is sampled across top-level entries instead of taking the modification-time head. */
|
||||
sampleOverCapGlobResults: boolean
|
||||
/** Max paths one `glob` call retains inline; later paths go to the formatted spill file. */
|
||||
globMaxResults?: number
|
||||
/** Max flat matches one `grep` call retains inline; later matches go to the formatted spill file. */
|
||||
@@ -73,6 +75,7 @@ export interface Config {
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
sampleOverCapGlobResults: z.boolean().required(),
|
||||
globMaxResults: z.number().default(GLOB_MAX_RESULTS),
|
||||
grepMaxMatches: z.number().default(GREP_MAX_MATCHES),
|
||||
grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES),
|
||||
@@ -137,6 +140,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
return
|
||||
}
|
||||
applyGlobTool(ctx, {
|
||||
sampleOverCapGlobResults: resolved.sampleOverCapGlobResults,
|
||||
maxResults: resolved.globMaxResults,
|
||||
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
|
||||
Reference in New Issue
Block a user