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:
mesalogo
2026-09-25 23:58:21 +08:00
parent d4bc10c0f3
commit ff746bdc1a
16 changed files with 418 additions and 513 deletions
+87 -240
View File
@@ -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
}