fix: preserve native ripgrep capabilities for direct models
Replace the restricted workspace_rg parameter mapping with native args and optional cwd. Preserve rg output and exit codes, and reuse process cancellation and output_read pagination so large search results remain accessible. Ask remains read-only within the workspace; Execute uses current-account permissions. Validation: focused regression 169 passed, 1 skipped; typecheck and lint passed. Real DeepSeek Ask and Execute pagination scenarios passed with six counted requests in the successful run; an earlier failed fixture run has no retained request count. Full-suite validation is not green: the recorded run had unrelated failures and the latest run timed out without a summary. The remote Agent does not use this local execution path. Release note: 直连模型可使用 ripgrep 原生参数搜索文件并保留原生结果,大输出可通过 output_read 完整续读。Ask 保持工作区只读,Execute 使用当前账号权限。
This commit is contained in:
@@ -1,259 +1,106 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { isAbsolute, relative, resolve } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import type { WorkspaceAccess } from '../workspace'
|
||||
import { LocalDirectModelProcessService } from './direct-model-process-service'
|
||||
|
||||
const MAXIMUM_CAPTURE_BYTES = 4 * 1024 * 1024
|
||||
const MAXIMUM_RESULT_BYTES = 240 * 1024
|
||||
export const ripgrepInputSchema = z.object({
|
||||
args: z.array(z.string().refine((value) => !value.includes('\0'), 'Invalid argument')),
|
||||
cwd: z.string().min(1).refine((value) => !value.includes('\0'), 'Invalid directory').optional()
|
||||
}).strict()
|
||||
export type WorkspaceRipgrepInput = z.infer<typeof ripgrepInputSchema>
|
||||
|
||||
export type WorkspaceRipgrepInput = {
|
||||
pattern?: string
|
||||
path: string
|
||||
glob: string[]
|
||||
fixedStrings: boolean
|
||||
ignoreCase: boolean
|
||||
filesOnly: boolean
|
||||
maxResults: number
|
||||
}
|
||||
// Only Ask needs argument inspection: these options consume the next argument.
|
||||
const valueOptions = new Set([
|
||||
'after-context', 'before-context', 'context', 'context-separator', 'colors', 'color',
|
||||
'dfa-size-limit', 'encoding', 'engine', 'field-context-separator', 'field-match-separator',
|
||||
'glob', 'iglob', 'max-count', 'max-columns', 'max-depth', 'max-filesize',
|
||||
'path-separator', 'regex-size-limit', 'replace', 'sort', 'sortr', 'threads',
|
||||
'type', 'type-not', 'type-add', 'type-clear', 'regexp', 'file', 'ignore-file',
|
||||
'hyperlink-format', 'pre-glob'
|
||||
])
|
||||
const shortValueOptions = new Set('ABCEefgMmrjtT'.split(''))
|
||||
|
||||
type RipgrepProcessResult = {
|
||||
stdout: string
|
||||
stderr: string
|
||||
code: number | string | null | undefined
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
function runRipgrep(
|
||||
executablePath: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
signal: AbortSignal
|
||||
): Promise<RipgrepProcessResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
executablePath,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: MAXIMUM_CAPTURE_BYTES,
|
||||
signal,
|
||||
windowsHide: true
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason ?? error)
|
||||
return
|
||||
async function checkAskArguments(input: WorkspaceRipgrepInput, workspace: WorkspaceAccess, signal: AbortSignal): Promise<void> {
|
||||
const identity = await workspace.getIdentity()
|
||||
const root = identity.canonicalDisplayPath
|
||||
const cwd = resolve(root, input.cwd ?? '.')
|
||||
const paths: string[] = [cwd]
|
||||
const positional: string[] = []
|
||||
let hasPattern = false
|
||||
let filesOnly = false
|
||||
let literal = false
|
||||
for (let i = 0; i < input.args.length; i++) {
|
||||
const arg = input.args[i]!
|
||||
if (literal || !arg.startsWith('-') || arg === '-') {
|
||||
positional.push(arg)
|
||||
continue
|
||||
}
|
||||
if (arg === '--') { literal = true; continue }
|
||||
if (arg.startsWith('--')) {
|
||||
const [name, ...inline] = arg.slice(2).split('=')
|
||||
if (['pre', 'hostname-bin', 'follow', 'search-zip'].includes(name!)) {
|
||||
throw new Error(`Ask mode does not allow --${name}; use Execute mode.`)
|
||||
}
|
||||
if (name === 'files') filesOnly = true
|
||||
if (name === 'regexp' || name === 'file') hasPattern = true
|
||||
if (valueOptions.has(name!)) {
|
||||
const value = inline.length ? inline.join('=') : input.args[++i]
|
||||
if (value !== undefined && (name === 'file' || name === 'ignore-file')) paths.push(resolve(cwd, value))
|
||||
}
|
||||
} else {
|
||||
for (let j = 1; j < arg.length; j++) {
|
||||
const flag = arg[j]!
|
||||
if (flag === 'L' || flag === 'z') throw new Error(`Ask mode does not allow -${flag}; use Execute mode.`)
|
||||
if (flag === 'e' || flag === 'f') hasPattern = true
|
||||
if (shortValueOptions.has(flag)) {
|
||||
const value = arg.slice(j + 1) || input.args[++i]
|
||||
if (flag === 'f' && value !== undefined) paths.push(resolve(cwd, value))
|
||||
break
|
||||
}
|
||||
const processError = error as (Error & {
|
||||
code?: number | string
|
||||
killed?: boolean
|
||||
}) | null
|
||||
const output = stdout
|
||||
const errors = stderr
|
||||
if (
|
||||
processError &&
|
||||
processError.code !== 1 &&
|
||||
processError.code !== 2 &&
|
||||
processError.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
|
||||
) {
|
||||
reject(
|
||||
new Error(
|
||||
errors.trim().slice(0, 2_000) ||
|
||||
processError.message ||
|
||||
'ripgrep 执行失败',
|
||||
{ cause: processError }
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
resolve({
|
||||
stdout: output,
|
||||
stderr: errors,
|
||||
code: processError?.code ?? 0,
|
||||
truncated:
|
||||
processError?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeResultPath(value: string): string {
|
||||
return value.replaceAll('\\', '/').replace(/^\.\//u, '')
|
||||
}
|
||||
|
||||
function appendBounded(
|
||||
lines: string[],
|
||||
line: string,
|
||||
state: { bytes: number }
|
||||
): boolean {
|
||||
const bytes = Buffer.byteLength(line) + 1
|
||||
if (state.bytes + bytes > MAXIMUM_RESULT_BYTES) {
|
||||
return false
|
||||
}
|
||||
lines.push(line)
|
||||
state.bytes += bytes
|
||||
return true
|
||||
}
|
||||
|
||||
function formatFileResults(
|
||||
stdout: string,
|
||||
maximumResults: number,
|
||||
initiallyTruncated: boolean
|
||||
): string {
|
||||
const paths = stdout
|
||||
.split('\0')
|
||||
.filter(Boolean)
|
||||
.map(normalizeResultPath)
|
||||
const lines: string[] = []
|
||||
const state = { bytes: 0 }
|
||||
let truncated = initiallyTruncated || paths.length > maximumResults
|
||||
for (const path of paths.slice(0, maximumResults)) {
|
||||
if (!appendBounded(lines, path, state)) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (truncated) {
|
||||
lines.push(`...[truncated after ${lines.length} files]`)
|
||||
}
|
||||
return lines.join('\n') || '[no files found]'
|
||||
}
|
||||
|
||||
function textField(value: unknown): string | undefined {
|
||||
if (!value || typeof value !== 'object' || !('text' in value)) {
|
||||
return undefined
|
||||
}
|
||||
return typeof value.text === 'string' ? value.text : undefined
|
||||
}
|
||||
|
||||
function formatMatchResults(
|
||||
stdout: string,
|
||||
maximumResults: number,
|
||||
initiallyTruncated: boolean
|
||||
): string {
|
||||
const lines: string[] = []
|
||||
const state = { bytes: 0 }
|
||||
let truncated = initiallyTruncated
|
||||
for (const rawLine of stdout.split('\n')) {
|
||||
if (!rawLine) continue
|
||||
let event: unknown
|
||||
try {
|
||||
event = JSON.parse(rawLine)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
!event ||
|
||||
typeof event !== 'object' ||
|
||||
!('type' in event) ||
|
||||
event.type !== 'match' ||
|
||||
!('data' in event) ||
|
||||
!event.data ||
|
||||
typeof event.data !== 'object'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const data = event.data as Record<string, unknown>
|
||||
const path = textField(data.path)
|
||||
const matchedLines = textField(data.lines)
|
||||
const lineNumber = data.line_number
|
||||
const submatches = data.submatches
|
||||
if (
|
||||
path === undefined ||
|
||||
matchedLines === undefined ||
|
||||
typeof lineNumber !== 'number' ||
|
||||
!Array.isArray(submatches)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const first = submatches[0]
|
||||
const byteColumn =
|
||||
first &&
|
||||
typeof first === 'object' &&
|
||||
'start' in first &&
|
||||
typeof first.start === 'number'
|
||||
? first.start + 1
|
||||
: 1
|
||||
const linePrefix = Buffer.from(matchedLines).subarray(0, byteColumn - 1)
|
||||
const column = [...linePrefix.toString('utf8')].length + 1
|
||||
const preview = matchedLines
|
||||
.replaceAll('\r', '')
|
||||
.replaceAll('\n', '\\n')
|
||||
.slice(0, 1_000)
|
||||
const formatted = `${normalizeResultPath(path)}:${lineNumber}:${column}:${preview}`
|
||||
if (
|
||||
lines.length >= maximumResults ||
|
||||
!appendBounded(lines, formatted, state)
|
||||
) {
|
||||
truncated = true
|
||||
break
|
||||
if (!hasPattern && !filesOnly) positional.shift()
|
||||
paths.push(...positional.filter((path) => path !== '-').map((path) => resolve(cwd, path)))
|
||||
for (const path of paths) {
|
||||
const local = relative(root, path)
|
||||
if (isAbsolute(local) || local.split(/[\\/]/u).includes('..')) throw new Error('文件路径不能超出项目工作区')
|
||||
if (local) {
|
||||
try {
|
||||
await workspace.stat({ path: local, signal })
|
||||
} catch (error) {
|
||||
signal.throwIfAborted()
|
||||
// Let rg report missing search targets with its native exit code and stderr.
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
if (truncated) {
|
||||
lines.push(`...[truncated after ${lines.length} matches]`)
|
||||
}
|
||||
return lines.join('\n') || '[no matches found]'
|
||||
}
|
||||
|
||||
export async function searchWorkspaceWithRipgrep(
|
||||
executablePath: string,
|
||||
input: WorkspaceRipgrepInput,
|
||||
workspace: WorkspaceAccess,
|
||||
signal: AbortSignal
|
||||
): Promise<string> {
|
||||
const identity = await workspace.getIdentity()
|
||||
if (identity.kind !== 'local') {
|
||||
throw new Error('内置 ripgrep 当前只支持本机工作区')
|
||||
}
|
||||
if (input.path !== '.') {
|
||||
await workspace.stat({ path: input.path, signal })
|
||||
}
|
||||
const args = ['--no-config', '--hidden', '--glob', '!.git/**']
|
||||
for (const glob of input.glob) {
|
||||
args.push('--glob', glob)
|
||||
}
|
||||
if (input.filesOnly) {
|
||||
args.push('--files', '--null', '--', input.path)
|
||||
} else {
|
||||
args.push('--json')
|
||||
if (input.fixedStrings) args.push('--fixed-strings')
|
||||
if (input.ignoreCase) args.push('--ignore-case')
|
||||
args.push('--regexp', input.pattern!, '--', input.path)
|
||||
}
|
||||
const result = await runRipgrep(
|
||||
executablePath,
|
||||
args,
|
||||
identity.canonicalDisplayPath,
|
||||
signal
|
||||
)
|
||||
const incomplete = result.code === 2
|
||||
if (
|
||||
incomplete &&
|
||||
(!result.stdout.trim() ||
|
||||
/regex parse error|error parsing (?:regex|glob)|PCRE2: error compiling/iu.test(result.stderr))
|
||||
) {
|
||||
throw new Error(
|
||||
`ripgrep failed (exit code 2); search coverage is incomplete.\n${result.stderr.trim().slice(0, 2_000) || 'No search output was available.'}`
|
||||
signal: AbortSignal,
|
||||
service: LocalDirectModelProcessService,
|
||||
conversationId: string,
|
||||
workMode: 'ask' | 'execute'
|
||||
) {
|
||||
signal.throwIfAborted()
|
||||
if (workMode === 'ask') await checkAskArguments(input, workspace, signal)
|
||||
let execution
|
||||
try {
|
||||
execution = await service.executeFile(
|
||||
executablePath, ['--no-config', '--color=never', '--line-number', ...input.args],
|
||||
{ workspace, signal, conversationId }, input.cwd
|
||||
)
|
||||
} catch (error) {
|
||||
signal.throwIfAborted()
|
||||
// Spawn failures are installation/environment failures, not search errors.
|
||||
throw new Error('Unable to launch bundled ripgrep', { cause: error })
|
||||
}
|
||||
const formatted = input.filesOnly
|
||||
? formatFileResults(
|
||||
result.stdout,
|
||||
input.maxResults,
|
||||
result.truncated
|
||||
)
|
||||
: formatMatchResults(
|
||||
result.stdout,
|
||||
input.maxResults,
|
||||
result.truncated
|
||||
)
|
||||
if (incomplete) {
|
||||
const warning = '[warning: incomplete search coverage (ripgrep exit code 2). Some files or directories could not be searched; missing results do not establish absence.]'
|
||||
const details = result.stderr.trim().slice(0, 2_000)
|
||||
const partial = formatted
|
||||
.replace(/^\[no matches found\]$/u, '[no matches found in searched files]')
|
||||
.replace(/^\[no files found\]$/u, '[no files found in searched locations]')
|
||||
return `${warning}\n${details ? `${details}\n` : ''}${partial}`
|
||||
}
|
||||
return formatted
|
||||
const { shell, ...result } = execution
|
||||
void shell
|
||||
signal.throwIfAborted()
|
||||
return result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user