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
+40 -18
View File
@@ -52,10 +52,6 @@ export type ProcessExecuteInput = z.input<
typeof processExecuteInputSchema
>
type ParsedProcessExecuteInput = z.output<
typeof processExecuteInputSchema
>
export type ProcessShellSummary = {
kind: 'powershell' | 'bash' | 'sh'
label: string
@@ -132,6 +128,7 @@ export type DirectModelProcessSpawn = (
) => DirectModelProcessChild
export type LocalDirectModelProcessServiceOptions = {
outputPrefix?: string
platform?: NodeJS.Platform
environment?: NodeJS.ProcessEnv
toolBinDirectory?: string
@@ -296,7 +293,7 @@ function abortError(message: string): DOMException {
}
async function resolveWorkingDirectory(
input: ParsedProcessExecuteInput,
input: { cwd?: string },
workspace: WorkspaceAccess
): Promise<{ canonicalPath: string; displayPath: string }> {
if (input.cwd?.includes('\0')) {
@@ -343,7 +340,7 @@ export class LocalDirectModelProcessService
implements DirectModelProcessService
{
private readonly activeCalls = new Map<string, Set<ActiveCall>>()
private readonly outputs = new PagedOutputStore('process')
private readonly outputs: PagedOutputStore
private readonly shell: Promise<ResolvedProcessShell | undefined>
private disposed = false
private disposePromise?: Promise<void>
@@ -351,6 +348,7 @@ export class LocalDirectModelProcessService
constructor(
private readonly options: LocalDirectModelProcessServiceOptions = {}
) {
this.outputs = new PagedOutputStore(options.outputPrefix ?? 'process')
this.shell = resolveProcessShell(options)
}
@@ -375,6 +373,34 @@ export class LocalDirectModelProcessService
context: DirectModelProcessExecutionContext
): Promise<ProcessExecuteResult> {
const parsed = processExecuteInputSchema.parse(input)
return this.trackExecution(async (signal) => {
const shell = await this.shell
signal.throwIfAborted()
if (!shell) throw new Error('当前平台没有可用的命令 Shell')
return this.executeTracked({
executable: shell.executable, args: shellArguments(shell, parsed.command),
cwd: parsed.cwd, timeoutMs: parsed.timeoutMs, shell
}, context, signal)
}, context)
}
async executeFile(
executable: string,
args: string[],
context: DirectModelProcessExecutionContext,
cwd?: string,
timeoutMs?: number,
shell: ProcessShellSummary = { kind: 'sh', label: executable }
): Promise<ProcessExecuteResult> {
return this.trackExecution((signal) => this.executeTracked(
{ executable, args, cwd, timeoutMs, shell }, context, signal
), context)
}
private async trackExecution(
run: (signal: AbortSignal) => Promise<ProcessExecuteResult>,
context: DirectModelProcessExecutionContext
): Promise<ProcessExecuteResult> {
if (this.disposed) {
throw new Error('进程执行服务已关闭')
}
@@ -406,7 +432,7 @@ export class LocalDirectModelProcessService
}
try {
return await this.executeTracked(parsed, context, controller.signal)
return await run(controller.signal)
} finally {
context.signal.removeEventListener('abort', cancelFromCaller)
calls.delete(active)
@@ -418,16 +444,12 @@ export class LocalDirectModelProcessService
}
private async executeTracked(
input: ParsedProcessExecuteInput,
input: { executable: string; args: string[]; cwd?: string; timeoutMs?: number; shell: ProcessShellSummary },
context: DirectModelProcessExecutionContext,
signal: AbortSignal
): Promise<ProcessExecuteResult> {
signal.throwIfAborted()
const shell = await this.shell
if (!shell) {
throw new Error('当前平台没有可用的命令 Shell')
}
signal.throwIfAborted()
const shell = input.shell
const workingDirectory = await resolveWorkingDirectory(
input,
context.workspace
@@ -455,7 +477,7 @@ export class LocalDirectModelProcessService
child = (
this.options.spawnProcess ??
(spawn as unknown as DirectModelProcessSpawn)
)(shell.executable, shellArguments(shell, input.command), {
)(input.executable, input.args, {
cwd: workingDirectory.canonicalPath,
detached: platform !== 'win32',
env: environment,
@@ -485,9 +507,9 @@ export class LocalDirectModelProcessService
private waitForProcess(
child: DirectModelProcessChild,
shell: ResolvedProcessShell,
shell: ProcessShellSummary,
cwd: string,
timeoutMs: number,
timeoutMs: number | undefined,
cancellationSignal: AbortSignal,
startedAt: number,
stdout: PagedOutputWriter,
@@ -596,14 +618,14 @@ export class LocalDirectModelProcessService
failure = error
startCleanup()
}
const timeout = setTimeout(() => {
const timeout = timeoutMs === undefined ? undefined : setTimeout(() => {
if (settled || terminationReason === 'cancelled') {
return
}
terminationReason = 'timeout'
startCleanup()
}, timeoutMs)
timeout.unref?.()
timeout?.unref?.()
child.stdout?.once('error', failFromOutput)
child.stderr?.once('error', failFromOutput)
+70 -120
View File
@@ -1,138 +1,88 @@
import { execFile, type ChildProcess } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { rgPath } from '@vscode/ripgrep'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { LocalWorkspaceAccess } from '../workspace'
import { searchWorkspaceWithRipgrep, type WorkspaceRipgrepInput } from './direct-model-ripgrep'
import { LocalDirectModelProcessService } from './direct-model-process-service'
import { searchWorkspaceWithRipgrep } from './direct-model-ripgrep'
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>()
const mocked = { ...actual, execFile: vi.fn(actual.execFile) }
return { ...mocked, default: mocked }
let root: string
let workspace: LocalWorkspaceAccess
let service: LocalDirectModelProcessService
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'goodbuddy-rg-'))
workspace = new LocalWorkspaceAccess(root)
service = new LocalDirectModelProcessService()
await writeFile(join(root, 'valid.txt'), 'before\ntarget\nafter\n')
})
const input: WorkspaceRipgrepInput = {
path: '.', pattern: 'target', glob: [], fixedStrings: false,
ignoreCase: false, filesOnly: false, maxResults: 100
}
const match = JSON.stringify({
type: 'match',
data: {
path: { text: './valid.txt' }, lines: { text: 'target\n' },
line_number: 1, submatches: [{ start: 0 }]
}
}) + '\n'
const accessError = 'blocked: Permission denied (os error 13)'
const directories: string[] = []
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process')
function mockResult(code: number | string, stdout: string, stderr: string) {
const error = Object.assign(new Error(`process failed: ${code}`), { code })
vi.mocked(execFile).mockImplementation((file, args, options, callback) => {
if (file !== rgPath) return actual.execFile(file, args, options, callback)
const complete = callback as (error: Error, stdout: string, stderr: string) => void
complete(error, stdout, stderr)
return {} as ChildProcess
})
return error
}
function search(overrides: Partial<WorkspaceRipgrepInput> = {}, signal = new AbortController().signal) {
return searchWorkspaceWithRipgrep(
rgPath, { ...input, ...overrides }, new LocalWorkspaceAccess(process.cwd()), signal
)
}
afterEach(async () => {
vi.mocked(execFile).mockReset()
await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true })))
await service.dispose()
await rm(root, { recursive: true, force: true })
})
const search = (args: string[], workMode: 'ask' | 'execute' = 'execute', signal = new AbortController().signal) =>
searchWorkspaceWithRipgrep(rgPath, { args }, workspace, signal, service, 'owner', workMode)
describe('searchWorkspaceWithRipgrep', () => {
it('preserves formatted matches with bounded traversal diagnostics and an explicit warning', async () => {
mockResult(2, match, accessError + '\n' + 'x'.repeat(10_000))
const result = await search()
expect(vi.mocked(execFile).mock.calls.map((call) => call[0])).toContain(rgPath)
expect(result).toContain('valid.txt:1:1:target\\n')
expect(result).toContain('incomplete search coverage')
expect(result).toContain('missing results do not establish absence')
expect(result).toContain(accessError)
expect(result.length).toBeLessThan(2_500)
describe('native ripgrep', () => {
it('preserves native context, file listing, JSON and multiple expressions', async () => {
expect((await search(['-A1', '-B1', '-e', 'target', '-e', 'absent', '.'], 'ask')).stdout).toContain('after')
expect((await search(['--files', '-g', '*.txt'], 'ask')).stdout).toContain('valid.txt')
expect((await search(['--json', 'target', '.'])).stdout).toContain('"type":"match"')
})
it('preserves file listings and existing result truncation', async () => {
mockResult(2, './valid.txt\0./other.txt\0', accessError)
const result = await search({ filesOnly: true, maxResults: 1 })
expect(result).toContain('incomplete search coverage')
expect(result).toContain('\nvalid.txt\n...[truncated after 1 files]')
expect(result).not.toContain('other.txt')
it('preserves no matches, regex errors and partial results with exit codes', async () => {
expect(await search(['absent', '.'])).toMatchObject({ exitCode: 1, stdout: '' })
expect(await search(['[', '.'])).toMatchObject({ exitCode: 2, stderr: expect.stringContaining('regex parse error') })
expect(await search(['target', 'valid.txt', 'missing.txt'], 'ask')).toMatchObject({
exitCode: 2, stdout: expect.stringContaining('target'), stderr: expect.stringContaining('missing.txt')
})
})
it('qualifies a no-match summary when traversal was incomplete', async () => {
mockResult(2, JSON.stringify({ type: 'summary', data: {} }) + '\n', accessError)
const result = await search()
expect(result).toContain('incomplete search coverage')
expect(result).toContain('[no matches found in searched files]')
expect(result).not.toContain('[no matches found]')
it('retains more than the former 4 MiB capture and resumes through EOF', async () => {
const text = 'target '.repeat(700_000) + 'END-MARKER\n'
await writeFile(join(root, 'large.txt'), text)
const result = await search(['--no-line-number', 'target', 'large.txt'])
expect(result.exitCode).toBe(0)
expect(result.stdoutTruncated).toBe(true)
const reference = result.stdoutReference!
let output = result.stdout
let cursor = reference.nextCursor
while (cursor < reference.totalBytes) {
const page = await service.readOutput('owner', reference.handle, cursor)
output += page.content
cursor = page.nextCursor
}
expect(output).toBe(text)
await expect(service.readOutput('another', reference.handle)).rejects.toThrow()
await service.releaseConversation('owner')
await expect(service.readOutput('owner', reference.handle)).rejects.toThrow()
})
it.each([false, true])('throws detailed errors with no stdout (filesOnly=%s)', async (filesOnly) => {
mockResult(2, '', accessError)
await expect(search({ filesOnly })).rejects.toThrow(accessError)
it.each([['--pre=cmd', 'target'], ['--hostname-bin', 'cmd', 'target'], ['-L', 'target'], ['-z', 'target'], ['target', '../outside'], ['-f../outside']])('keeps Ask boundaries for %j', async (...args) => {
await expect(search(args, 'ask')).rejects.toThrow()
})
it('does not turn invalid regex diagnostics into successful partial output', async () => {
mockResult(2, match, 'regex parse error:\n [\nerror: unclosed character class')
await expect(search({ pattern: '[' })).rejects.toThrow('unclosed character class')
it('supports cwd and literal shell characters while ignoring external config', async () => {
await mkdir(join(root, 'nested'))
await writeFile(join(root, 'nested', 'text.txt'), '$(echo unexpected); target\n')
await writeFile(join(root, 'config'), '--invalid-config-option\n')
const configured = new LocalDirectModelProcessService({ environment: { ...process.env, RIPGREP_CONFIG_PATH: join(root, 'config') } })
try {
const result = await searchWorkspaceWithRipgrep(rgPath, {
args: ['-F', '$(echo unexpected);', '.'], cwd: 'nested'
}, workspace, new AbortController().signal, configured, 'owner', 'ask')
expect(result).toMatchObject({ exitCode: 0, stdout: expect.stringContaining('$(echo unexpected); target') })
} finally { await configured.dispose() }
})
it('keeps launch failures as errors even with stdout', async () => {
const error = mockResult('ENOENT', match, '')
await expect(search()).rejects.toMatchObject({ cause: error })
it('allows external Execute paths and rejects Ask directory links', async () => {
const outside = await mkdtemp(join(tmpdir(), 'goodbuddy-rg-outside-'))
try {
await writeFile(join(outside, 'outside.txt'), 'target\n')
await symlink(outside, join(root, 'linked'), process.platform === 'win32' ? 'junction' : 'dir')
expect((await search(['target', outside])).exitCode).toBe(0)
await expect(search(['target', outside], 'ask')).rejects.toThrow('不能超出')
await expect(search(['target', 'linked'], 'ask')).rejects.toThrow()
} finally { await rm(outside, { recursive: true, force: true }) }
})
it('preserves the cancellation reason even when partial stdout is available', async () => {
it('preserves cancellation', async () => {
const controller = new AbortController()
const reason = new Error('cancel search')
controller.abort(reason)
mockResult(2, match, accessError)
await expect(search({}, controller.signal)).rejects.toBe(reason)
})
it('keeps exit 1 as a normal no-match result', async () => {
mockResult(1, '', '')
await expect(search()).resolves.toBe('[no matches found]')
})
it('preserves capture-limit truncation', async () => {
mockResult('ERR_CHILD_PROCESS_STDIO_MAXBUFFER', match, '')
await expect(search()).resolves.toBe('valid.txt:1:1:target\\n\n...[truncated after 1 matches]')
})
it.each([
{ filesOnly: false, pattern: 'target', expected: 'valid.txt:1:1:target\\n' },
{ filesOnly: true, pattern: 'target', expected: '\nvalid.txt' },
{ filesOnly: false, pattern: 'absent', expected: '[no matches found in searched files]' }
])('handles real bundled rg with a valid file plus a missing target ($filesOnly, $pattern)', async ({ filesOnly, pattern, expected }) => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-ripgrep-'))
directories.push(directory)
await writeFile(join(directory, 'valid.txt'), 'target\n')
// Add a second target at the process boundary: the public API stats its single target first.
vi.mocked(execFile).mockImplementation((file, args, options, callback) =>
actual.execFile(file, file === rgPath ? [...args as string[], 'missing.txt'] : args, options, callback)
)
const result = await searchWorkspaceWithRipgrep(
rgPath, { ...input, path: 'valid.txt', filesOnly, pattern },
new LocalWorkspaceAccess(directory), new AbortController().signal
)
expect(result).toContain(expected)
expect(result).toContain('incomplete search coverage (ripgrep exit code 2)')
expect(result).toContain('missing.txt')
})
it('rejects an invalid regex using the real bundled rg', async () => {
await expect(search({ pattern: '[' })).rejects.toThrow(/regex parse error/u)
controller.abort(new Error('cancelled'))
await expect(search(['target'], 'execute', controller.signal)).rejects.toThrow('cancelled')
})
})
+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
}
+2 -2
View File
@@ -2234,7 +2234,7 @@ describe('ModelAgentRuntime', () => {
const reference = previous.stdoutReference!
expect(reference).toBeDefined()
const calls = [
{ name: 'workspace_rg', arguments: { pattern: 'readonly-marker', glob: ['README.md'] } },
{ name: 'workspace_rg', arguments: { args: ['-g', 'README.md', 'readonly-marker'] } },
{ name: 'workspace_read_text', arguments: { path: 'README.md' } },
{ name: 'output_read', arguments: { handle: reference.handle, cursor: reference.nextCursor } }
]
@@ -2261,7 +2261,7 @@ describe('ModelAgentRuntime', () => {
(message: { role: string }) => message.role === 'tool'
)
expect(results).toHaveLength(3)
expect(results[0].content).toContain('README.md:1:1:readonly-marker')
expect(results[0].content).toContain('README.md:1:readonly-marker')
expect(results[1].content).toContain('readonly-marker')
expect(JSON.parse(results[2].content)).toMatchObject({
content: expect.stringContaining('retained-tail'), eof: true
+17 -22
View File
@@ -416,7 +416,7 @@ describe('ModelToolProvider', () => {
})
const listing = await provider.callTool(
'workspace_rg',
{ path: 'docs', filesOnly: true },
{ args: ['--files', '--path-separator=/', 'docs'] },
signal,
toolContext
)
@@ -428,14 +428,14 @@ describe('ModelToolProvider', () => {
])
const matches = await provider.callTool(
'workspace_rg',
{ path: 'docs', pattern: 'target' },
{ args: ['--path-separator=/', 'target', 'docs'] },
signal,
toolContext
)
expect(matches.parts).toEqual([
expect.objectContaining({
type: 'text',
text: expect.stringContaining('docs/search.txt:1:2:中target')
text: expect.stringContaining('docs/search.txt:1:中target')
})
])
const written = await provider.callTool(
@@ -493,16 +493,15 @@ describe('ModelToolProvider', () => {
const signal = new AbortController().signal
try {
for (const args of [
{ pattern: 'x', path: workspace }, { pattern: 'x', path: 'missing' },
{ pattern: '[' }, { pattern: 'x', glob: ['['] }, { maxResults: 0 }
{ args: 'x' }, { args: [1] }, { args: ['x'], extra: true }
]) {
await expect(provider.callTool('workspace_rg', args, signal, toolContext)).rejects.toMatchObject({
name: 'RecoverableModelToolError',
nextAction: expect.stringContaining('adjust the search path/globs')
nextAction: expect.stringContaining('Correct native rg args')
})
}
await expect(provider.callTool('workspace_rg', { pattern: '[', fixedStrings: true }, signal, toolContext))
.resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('note.txt:1:1:') }] })
await expect(provider.callTool('workspace_rg', { args: ['-F', '[', 'note.txt'] }, signal, toolContext))
.resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('1:[literal') }] })
} finally {
await provider.dispose()
}
@@ -539,20 +538,14 @@ describe('ModelToolProvider', () => {
}
})
it('recovers the wrapped rg exit code 2 but leaves a missing executable fatal', async () => {
it('leaves a missing rg executable fatal', async () => {
const workspace = await createWorkspace()
const access = new LocalWorkspaceAccess(workspace)
const failure = new Error('regex parse error', {
cause: Object.assign(new Error('process failed'), { code: 2 })
})
vi.spyOn(access, 'stat').mockRejectedValueOnce(failure)
const provider = new ModelToolProvider(access, [], undefined, undefined, false, {
ripgrepExecutablePath: join(workspace, 'missing-rg.exe')
})
try {
await expect(provider.callTool('workspace_rg', { path: 'note.txt', pattern: '[' }, new AbortController().signal, toolContext))
.rejects.toMatchObject({ name: 'RecoverableModelToolError', cause: failure })
const error = await provider.callTool('workspace_rg', { pattern: 'x' }, new AbortController().signal, toolContext)
const error = await provider.callTool('workspace_rg', { args: ['x'] }, new AbortController().signal, toolContext)
.catch((error: unknown) => error)
expect(error).toBeInstanceOf(Error)
expect(error).not.toBeInstanceOf(RecoverableModelToolError)
@@ -566,18 +559,19 @@ describe('ModelToolProvider', () => {
const access = new LocalWorkspaceAccess(await createWorkspace())
const provider = new ModelToolProvider(access, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath })
const operation = name === 'workspace_rg' ? vi.spyOn(access, 'stat') : vi.spyOn(access, 'readText')
const args = { path: 'note.txt', ...(name === 'workspace_rg' ? { pattern: 'x' } : {}) }
const args = name === 'workspace_rg' ? { args: ['x', 'note.txt'] } : { path: 'note.txt' }
const context = { ...toolContext, workMode: 'ask' as const }
try {
const abortError = new DOMException('cancelled', 'AbortError')
operation.mockRejectedValueOnce(abortError)
await expect(provider.callTool(name, args, new AbortController().signal, toolContext)).rejects.toBe(abortError)
await expect(provider.callTool(name, args, new AbortController().signal, context)).rejects.toBe(abortError)
const controller = new AbortController()
const reason = new Error('cancelled during access')
operation.mockImplementationOnce(async () => {
controller.abort(reason)
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
})
await expect(provider.callTool(name, args, controller.signal, toolContext)).rejects.toBe(reason)
await expect(provider.callTool(name, args, controller.signal, context)).rejects.toBe(reason)
await expect(provider.callTool(name, {}, controller.signal, toolContext)).rejects.toBe(reason)
} finally {
await provider.dispose()
@@ -1040,6 +1034,7 @@ describe('ModelToolProvider', () => {
} as unknown as KnowledgeMcpGateway
const context = {
conversationId: 'knowledge-capacity',
runtimeTarget: 'model',
workMode: 'execute',
knowledgeCapabilityToken: 'main-only-token'
} satisfies ModelToolCallContext
@@ -1055,7 +1050,7 @@ describe('ModelToolProvider', () => {
}))
mocks.client.listTools.mockResolvedValueOnce({
tools: createTools(82)
tools: createTools(81)
})
const validProvider = new ModelToolProvider(
workspace,
@@ -1071,7 +1066,7 @@ describe('ModelToolProvider', () => {
await validProvider.dispose()
mocks.client.listTools.mockResolvedValueOnce({
tools: createTools(83)
tools: createTools(82)
})
const overflowingProvider = new ModelToolProvider(
workspace,
@@ -1086,7 +1081,7 @@ describe('ModelToolProvider', () => {
context,
new AbortController().signal
)
).resolves.toHaveLength(18)
).resolves.toHaveLength(19)
await overflowingProvider.dispose()
})
+34 -77
View File
@@ -37,6 +37,7 @@ import {
normalizeMcpToolSchema
} from './mcp-tool-utils'
import {
LocalDirectModelProcessService,
processExecuteInputSchema,
type DirectModelProcessService
} from './direct-model-process-service'
@@ -48,7 +49,7 @@ import {
} from '../assistant/direct-model-subagent-service'
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
import type { BrowserTabId } from '../../shared/contracts'
import { searchWorkspaceWithRipgrep } from './direct-model-ripgrep'
import { ripgrepInputSchema, searchWorkspaceWithRipgrep } from './direct-model-ripgrep'
import { applyWorkspacePatch } from './workspace-apply-patch'
import { readWorkspaceLines } from './workspace-read-lines'
import {
@@ -143,33 +144,6 @@ const readInputSchema = z
})
.strict()
const searchPathSchema = z
.string()
.trim()
.min(1)
.refine((value) => !isAbsolute(value), '路径必须相对于工作区')
.refine((value) => !value.includes('\0'), '路径包含无效字符')
.refine(
(value) => !value.split(/[\\/]+/u).includes('..'),
'路径不能超出工作区'
)
const ripgrepInputSchema = z
.object({
pattern: z.string().optional(),
path: searchPathSchema.default('.'),
glob: z.array(z.string().min(1)).default([]),
fixedStrings: z.boolean().default(false),
ignoreCase: z.boolean().default(false),
filesOnly: z.boolean().default(false),
maxResults: z.number().int().min(1).default(200)
})
.refine(
(input) => input.filesOnly || Boolean(input.pattern),
'内容搜索必须提供 pattern'
)
.strict()
const applyPatchInputSchema = z
.object({
patch: z.string().min(1)
@@ -692,6 +666,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
: workspace
}
private readonly ripgrepService = new LocalDirectModelProcessService({ outputPrefix: 'rg' })
private readonly workspaceAccess: WorkspaceAccess
private getScopedTools(
@@ -766,7 +741,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
(this.webSearchEnabled ? 2 : 0) +
(this.programming.processService ? 1 : 0) +
(this.programming.subagentService ? 1 : 0) +
(this.programming.processService || this.programming.subagentService
(this.programming.ripgrepExecutablePath || this.programming.processService || this.programming.subagentService
? 1 : 0) +
(this.knowledgeGateway ? maximumScopedToolCount : 0)
)
@@ -835,34 +810,8 @@ export class ModelToolProvider implements ModelToolProviderLike {
name: workspaceRipgrepTool.name,
displayName: workspaceRipgrepTool.displayName,
description:
'Search workspace contents or list files with bundled ripgrep. Returns compact path:line:column:text output. Use filesOnly to discover files. Paths are relative to the workspace.',
inputSchema: {
type: 'object',
properties: {
pattern: {
type: 'string',
description: 'ripgrep 正则;filesOnly 为 false 时必填'
},
path: {
type: 'string',
description: '相对于工作区的搜索目录,默认为 .'
},
glob: {
type: 'array',
items: { type: 'string' },
description: '可选 ripgrep glob,例如 **/*.ts 或 !dist/**'
},
fixedStrings: { type: 'boolean', default: false },
ignoreCase: { type: 'boolean', default: false },
filesOnly: { type: 'boolean', default: false },
maxResults: {
type: 'integer',
minimum: 1,
default: 200
}
},
additionalProperties: false
},
'Run bundled rg with native args (no shell), e.g. ["--files","-g","*.ts"] or ["-n","-i","TODO","src"]. cwd defaults to the workspace. Returns native stdout, stderr and exitCode: 1 means no matches; 2 means incomplete search/error. Use output_read with output references to continue large results. Ask permits read-only workspace searches; Execute uses current-user permissions. External rg config is disabled.',
inputSchema: toModelToolJsonSchema(ripgrepInputSchema),
source: 'builtin'
},
{
@@ -1010,7 +959,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
): ModelToolDefinition[] {
if (
context.runtimeTarget !== 'model' ||
(!this.programming.processService && !this.programming.subagentService)
(!this.programming.ripgrepExecutablePath && !this.programming.processService && !this.programming.subagentService)
) {
return []
}
@@ -1019,7 +968,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
name: outputReadTool.name,
displayName: outputReadTool.displayName,
description:
'Read retained process or Subagent output from this conversation. ' +
'Read retained search, process or Subagent output from this conversation. ' +
'Use the reference handle and nextCursor to continue a preview, or cursor 0 to reread. ' +
'Follow each page nextCursor until eof; cursors are UTF-8 byte offsets. ' +
'Read-only in Ask and Execute. Handles expire when the conversation or runtime is released.',
@@ -1397,7 +1346,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return {
scopeKey: 'model:builtin:output_read',
title: '允许续读工具输出?',
description: '读取当前会话已保留的进程或 Subagent 输出,不运行新命令。',
description: '读取当前会话已保留的搜索、进程或 Subagent 输出,不运行新命令。',
toolName: tool.displayName,
argumentSummary,
allowPermanent: false
@@ -1426,9 +1375,11 @@ export class ModelToolProvider implements ModelToolProviderLike {
description:
tool.source === 'mcp'
? `该工具由已启用的 MCP Server「${tool.serverName ?? '未知'}」执行,并使用当前用户权限。`
: path
? `目标位于当前工作区:${path}`
: '该工具仅允许访问当前工作区。',
: tool.name === 'workspace_rg' && context.workMode === 'execute'
? '使用当前用户权限运行内置 rg。'
: path
? `目标位于当前工作区:${path}`
: '该工具仅允许访问当前工作区。',
toolName: tool.displayName,
argumentSummary,
allowPermanent: false
@@ -1746,13 +1697,18 @@ export class ModelToolProvider implements ModelToolProviderLike {
throw new Error('GoodBuddy 内置 ripgrep 不可用')
}
const input = ripgrepInputSchema.parse(argumentsValue)
return createTextToolResult(
return createBoundedJsonObjectToolResult(
await searchWorkspaceWithRipgrep(
this.programming.ripgrepExecutablePath,
input,
this.workspaceAccess,
signal
)
signal,
this.ripgrepService,
context.conversationId,
context.workMode
),
['stdout', 'stderr'],
'ripgrep output could not be serialized'
)
}
const input = readInputSchema.parse(argumentsValue)
@@ -1780,21 +1736,18 @@ export class ModelToolProvider implements ModelToolProviderLike {
'工作区读取目标不是有效 UTF-8 文本',
'工作区读取范围无效'
].includes(error.message)
const rgExecutionError = name === 'workspace_rg' && (
(error.cause instanceof Error && 'code' in error.cause && error.cause.code === 2) ||
error.message.startsWith('ripgrep failed (exit code 2); search coverage is incomplete.\n')
)
const rgModeError = name === 'workspace_rg' && error.message.startsWith('Ask mode does not allow ')
if (!(error instanceof z.ZodError) &&
!(typeof code === 'string' && expectedOsCodes.includes(code)) &&
!(typeof remoteCode === 'string' && [
...expectedOsCodes, 'invalid-path', 'invalid-utf8',
'not-directory', 'special-file', 'symlink-rejected'
].includes(remoteCode)) &&
!expectedWorkspaceError && !rgExecutionError) {
!expectedWorkspaceError && !rgModeError) {
throw error
}
let nextAction = name === 'workspace_rg'
? 'Use workspace-relative paths; adjust the search path/globs or correct the pattern (use fixedStrings for literal text).'
? 'Correct native rg args or cwd; use -F for literal text and workspace paths in Ask mode.'
: 'Use a workspace-relative path to an existing readable UTF-8 file; correct the path or offset/limit, and discover the filename if needed.'
nextAction += ' Do not retry identical arguments.'
if ((await this.getProcessTool(context, signal)).length > 0) {
@@ -1819,11 +1772,13 @@ export class ModelToolProvider implements ModelToolProviderLike {
throw new Error('当前请求不允许续读工具输出')
}
const input = outputReadInputSchema.parse(argumentsValue)
const service = input.handle.startsWith('process:')
? this.programming.processService
: input.handle.startsWith('subagent:')
? this.programming.subagentService
: undefined
const service = input.handle.startsWith('rg:')
? this.ripgrepService
: input.handle.startsWith('process:')
? this.programming.processService
: input.handle.startsWith('subagent:')
? this.programming.subagentService
: undefined
if (!service) {
throw new Error('分页输出句柄无效或服务不可用')
}
@@ -1963,6 +1918,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
this.webSearchBindings = undefined
await Promise.allSettled([
...clients.map((client) => client.close()),
this.ripgrepService.dispose(),
this.programming.processService?.dispose(),
this.programming.subagentService?.dispose(),
this.workspaceAccess.dispose()
@@ -1971,6 +1927,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
async releaseConversation(conversationId: string): Promise<void> {
await Promise.allSettled([
this.ripgrepService.releaseConversation(conversationId),
this.browserService?.releaseConversation(conversationId),
this.programming.processService?.releaseConversation(
conversationId
+47
View File
@@ -0,0 +1,47 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { rgPath } from '@vscode/ripgrep'
import { expect, it } from 'vitest'
import { ModelToolProvider, type ModelToolResult } from './model-tool-provider'
it('pages native rg output through the production provider in Ask and releases it', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-rg-provider-'))
const provider = new ModelToolProvider(root, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath })
const context = { conversationId: 'rg-pages', runtimeTarget: 'model' as const, workMode: 'ask' as const }
const signal = new AbortController().signal
const parse = (result: ModelToolResult) => {
expect(result.contextBytes).toBeLessThanOrEqual(256 * 1024)
const part = result.parts[0]!
if (part.type !== 'text') throw new Error('Expected text')
return JSON.parse(part.text)
}
try {
const text = 'target 你好\\"'.repeat(30_000) + 'tail\n'
await writeFile(join(root, 'large.txt'), text)
expect(await provider.listTools(context, signal)).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'workspace_rg', inputSchema: expect.objectContaining({ required: ['args'] }) }),
expect.objectContaining({ name: 'output_read' })
]))
const result = parse(await provider.callTool('workspace_rg', { args: ['--no-line-number', 'target', 'large.txt'] }, signal, context))
let output = result.stdout
const reference = result.stdoutReference
let cursor = reference.nextCursor
while (cursor < reference.totalBytes) {
const page = parse(await provider.callTool('output_read', { handle: reference.handle, cursor }, signal, context))
output += page.content
expect(page.nextCursor).toBeGreaterThan(cursor)
cursor = page.nextCursor
}
expect(output).toBe(text)
const error = parse(await provider.callTool('workspace_rg', { args: ['[', '.'] }, signal, context))
expect(error).toMatchObject({ exitCode: 2, stderr: expect.stringContaining('regex parse error') })
await expect(provider.callTool('workspace_rg', { args: ['--pre=cmd', 'target'] }, signal, context))
.rejects.toMatchObject({ name: 'RecoverableModelToolError' })
await provider.releaseConversation(context.conversationId)
await expect(provider.callTool('output_read', { handle: reference.handle }, signal, context)).rejects.toThrow('不存在')
} finally {
await provider.dispose()
await rm(root, { recursive: true, force: true })
}
})
+62
View File
@@ -480,6 +480,68 @@ describe.runIf(enabled)('runtime end-to-end', () => {
180_000
)
it.each(['ask', 'execute'] as const)(
'searches native rg arguments and reads hidden output through real model in %s',
async (workMode) => {
const marker = `RG_TAIL_${crypto.randomUUID()}`
await writeFile(join(workspace, 'native-rg.txt'), `target ${' '.repeat(110 * 1024)}${marker}\n`)
const upstreamUrl = protocol === 'anthropic-messages'
? createAnthropicMessagesUrl(baseUrl)
: protocol === 'openai-responses'
? createOpenAIResponsesUrl(baseUrl)
: createOpenAIChatCompletionsUrl(baseUrl)
const probe = await createModelRequestProbe({ upstreamUrl, headerName: 'x-goodbuddy-rg-test' })
const provider = new ModelToolProvider(workspace, [], undefined, undefined, false, {
ripgrepExecutablePath: join(process.cwd(), '.runtime-resources', process.arch, process.platform === 'win32' ? 'rg.exe' : 'rg')
})
const listTools = provider.listTools.bind(provider)
vi.spyOn(provider, 'listTools').mockImplementation(async (context, signal) =>
(await listTools(context, signal)).filter((tool) => ['workspace_rg', 'output_read'].includes(tool.name)))
const callTool = provider.callTool.bind(provider)
const observed: Array<{ name: string; result: ProcessExecuteResult | PagedOutputPage }> = []
vi.spyOn(provider, 'callTool').mockImplementation(async (name, args, signal, context) => {
if (observed.length >= 4) throw new Error('Real rg test exceeded four tool calls')
const result = await callTool(name, args, signal, context)
const part = result.parts[0]
if (part?.type === 'text') observed.push({ name, result: JSON.parse(part.text) })
return result
})
const runtime = new ModelAgentRuntime({
apiKey, baseUrl: probe.baseUrl, model: modelName, protocol,
authentication: 'api-key', defaultWorkspace: workspace,
maximumOutputTokens: 1024, toolProvider: provider
})
try {
const events = await collectEvents(runtime.run({
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), workMode,
prompt: [
'Call workspace_rg exactly once with args ["--no-line-number", "-F", "target", "native-rg.txt"].',
'The result has a hidden RG_TAIL_ marker after a long line of spaces.',
'Read the remaining output using output_read with stdoutReference.handle and stdoutReference.nextCursor.',
'Continue until eof. Reply only with the exact RG_TAIL_ marker. Do not guess or repeat the search.'
].join('\n')
}, AbortSignal.timeout(180000), async () => 'once'))
expect(observed.filter((item) => item.name === 'workspace_rg')).toHaveLength(1)
const search = observed[0]!.result as ProcessExecuteResult
expect(search.exitCode).toBe(0)
expect(search.stdoutTruncated).toBe(true)
expect(search.stdout).not.toContain(marker)
const pages = observed.filter((item) => item.name === 'output_read')
expect(pages.length).toBeGreaterThan(0)
expect(pages.at(-1)!.result).toMatchObject({ eof: true })
expect(events.filter((event) => event.type === 'text').map((event) => event.delta).join('')).toContain(marker)
expect(events.some((event) => event.type === 'tool' && event.state === 'completed')).toBe(true)
expect(events.at(-1)).toMatchObject({ type: 'done' })
} finally {
console.info(JSON.stringify({ boundary: 'native-rg-pagination', workMode,
realModelCalls: probe.observations.length, toolCalls: observed.map((item) => item.name) }))
await runtime.dispose()
await probe.close()
}
},
200000
)
it(
'recovers hidden process output markers through real-model output_read pagination',
async () => {