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:
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user