feat: improve direct model workspace tools
Direct models now use bundled ripgrep for workspace discovery, read files through resumable pages, and edit multiple files with apply_patch. Ask can search and read while Execute retains write access, and fixed total input limits no longer constrain these tools. Release note: 直连模型现可使用随包 ripgrep 搜索项目、分页读取任意大小的文本,并通过多文件补丁修改代码;设置中的相关能力统一显示为工作区文件和开发工具。
This commit is contained in:
@@ -8,7 +8,8 @@ describe('bundled runtime paths', () => {
|
||||
appPath: join('workspace', 'app'),
|
||||
resourcesPath: join('electron', 'resources'),
|
||||
packaged: false,
|
||||
platform: 'linux'
|
||||
platform: 'linux',
|
||||
arch: 'x64'
|
||||
})
|
||||
|
||||
expect(paths).toEqual({
|
||||
@@ -20,6 +21,15 @@ describe('bundled runtime paths', () => {
|
||||
'bin',
|
||||
'opencode.exe'
|
||||
),
|
||||
ripgrep: join(
|
||||
'workspace',
|
||||
'app',
|
||||
'node_modules',
|
||||
'@vscode',
|
||||
'ripgrep-linux-x64',
|
||||
'bin',
|
||||
'rg'
|
||||
),
|
||||
continue: join(
|
||||
'workspace',
|
||||
'app',
|
||||
@@ -61,6 +71,13 @@ describe('bundled runtime paths', () => {
|
||||
'runtimes',
|
||||
'opencode-config'
|
||||
),
|
||||
ripgrep: join(
|
||||
'installed',
|
||||
'resources',
|
||||
'runtimes',
|
||||
'opencode',
|
||||
'rg.exe'
|
||||
),
|
||||
continue: join(
|
||||
'installed',
|
||||
'resources',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
||||
export type BundledRuntimePaths = {
|
||||
opencode: string
|
||||
opencodeConfig?: string
|
||||
ripgrep: string
|
||||
continue: string
|
||||
deepseekHarness: string
|
||||
}
|
||||
@@ -15,11 +16,14 @@ export function resolveBundledRuntimePaths(input: {
|
||||
resourcesPath: string
|
||||
packaged: boolean
|
||||
platform?: NodeJS.Platform
|
||||
arch?: NodeJS.Architecture
|
||||
}): BundledRuntimePaths {
|
||||
const packagedExecutable =
|
||||
(input.platform ?? process.platform) === 'win32'
|
||||
? 'opencode.exe'
|
||||
: 'opencode'
|
||||
const ripgrepExecutable =
|
||||
(input.platform ?? process.platform) === 'win32' ? 'rg.exe' : 'rg'
|
||||
if (input.packaged) {
|
||||
return {
|
||||
opencode: join(
|
||||
@@ -33,6 +37,12 @@ export function resolveBundledRuntimePaths(input: {
|
||||
'runtimes',
|
||||
'opencode-config'
|
||||
),
|
||||
ripgrep: join(
|
||||
input.resourcesPath,
|
||||
'runtimes',
|
||||
'opencode',
|
||||
ripgrepExecutable
|
||||
),
|
||||
continue: join(
|
||||
input.resourcesPath,
|
||||
'runtimes',
|
||||
@@ -58,6 +68,14 @@ export function resolveBundledRuntimePaths(input: {
|
||||
'bin',
|
||||
'opencode.exe'
|
||||
),
|
||||
ripgrep: join(
|
||||
input.appPath,
|
||||
'node_modules',
|
||||
'@vscode',
|
||||
`ripgrep-${input.platform ?? process.platform}-${input.arch ?? process.arch}`,
|
||||
'bin',
|
||||
ripgrepExecutable
|
||||
),
|
||||
continue: join(
|
||||
input.appPath,
|
||||
'node_modules',
|
||||
|
||||
@@ -380,6 +380,7 @@ export function createAgentRuntime(
|
||||
workspaceAccess: getWorkspaceAccess(),
|
||||
directModelSubagentScheduler:
|
||||
capabilities.directModelSubagentScheduler,
|
||||
ripgrepExecutablePath: capabilities.bundledRuntimePaths?.ripgrep,
|
||||
launchEnvironmentProvider:
|
||||
capabilities.launchEnvironmentProvider
|
||||
})
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import type { WorkspaceAccess } from '../workspace'
|
||||
|
||||
const MAXIMUM_CAPTURE_BYTES = 4 * 1024 * 1024
|
||||
const MAXIMUM_RESULT_BYTES = 240 * 1024
|
||||
|
||||
export type WorkspaceRipgrepInput = {
|
||||
pattern?: string
|
||||
path: string
|
||||
glob: string[]
|
||||
fixedStrings: boolean
|
||||
ignoreCase: boolean
|
||||
filesOnly: boolean
|
||||
maxResults: number
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
const processError = error as (Error & {
|
||||
code?: number | string
|
||||
killed?: boolean
|
||||
}) | null
|
||||
const output = stdout
|
||||
const errors = stderr
|
||||
if (
|
||||
processError &&
|
||||
processError.code !== 1 &&
|
||||
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 (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
|
||||
)
|
||||
return input.filesOnly
|
||||
? formatFileResults(
|
||||
result.stdout,
|
||||
input.maxResults,
|
||||
result.truncated
|
||||
)
|
||||
: formatMatchResults(
|
||||
result.stdout,
|
||||
input.maxResults,
|
||||
result.truncated
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { dirname } from 'node:path'
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
AgentRuntimeStatus,
|
||||
@@ -269,6 +270,7 @@ export type ModelRuntimeOptions = {
|
||||
maximumOutputTokens?: number
|
||||
directModelSubagentScheduler?: SubagentScheduler
|
||||
launchEnvironmentProvider?: LaunchEnvironmentProvider
|
||||
ripgrepExecutablePath?: string
|
||||
}
|
||||
|
||||
function getErrorMessage(value: unknown): string | undefined {
|
||||
@@ -1654,8 +1656,14 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
options.knowledgeGateway,
|
||||
options.webSearchEnabled,
|
||||
{
|
||||
processService: new LocalDirectModelProcessService(),
|
||||
subagentService: directModelSubagentService
|
||||
processService: new LocalDirectModelProcessService({
|
||||
environment: options.launchEnvironmentProvider?.(),
|
||||
...(options.ripgrepExecutablePath
|
||||
? { toolBinDirectory: dirname(options.ripgrepExecutablePath) }
|
||||
: {})
|
||||
}),
|
||||
subagentService: directModelSubagentService,
|
||||
ripgrepExecutablePath: options.ripgrepExecutablePath
|
||||
},
|
||||
options.launchEnvironmentProvider
|
||||
)
|
||||
|
||||
@@ -45,12 +45,13 @@ import {
|
||||
directModelSubagentInputSchema
|
||||
} from '../assistant/direct-model-subagent-service'
|
||||
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
|
||||
import { searchWorkspaceWithRipgrep } from './direct-model-ripgrep'
|
||||
import { applyWorkspacePatch } from './workspace-apply-patch'
|
||||
import { readWorkspaceLines } from './workspace-read-lines'
|
||||
|
||||
const MAX_MODEL_TOOLS = 100
|
||||
const MAX_MCP_SERVERS = 16
|
||||
const MAX_TOOL_RESULT_BYTES = 256 * 1024
|
||||
const MAX_READ_BYTES = 256 * 1024
|
||||
const MAX_WRITE_BYTES = 512 * 1024
|
||||
const MCP_TIMEOUT_MS = 30_000
|
||||
const MCP_CALL_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000
|
||||
const MCP_TASK_CANCEL_TIMEOUT_MS = 5_000
|
||||
@@ -71,11 +72,15 @@ const EXA_TOOL_NAMES = new Set([
|
||||
'web_search_exa',
|
||||
'web_fetch_exa'
|
||||
])
|
||||
const [
|
||||
workspaceReadTextTool,
|
||||
workspaceListDirectoryTool,
|
||||
workspaceWriteTextTool
|
||||
] = builtinModelTools
|
||||
const workspaceRipgrepTool = builtinModelTools.find(
|
||||
(tool) => tool.name === 'workspace_rg'
|
||||
)!
|
||||
const workspaceReadTextTool = builtinModelTools.find(
|
||||
(tool) => tool.name === 'workspace_read_text'
|
||||
)!
|
||||
const workspaceApplyPatchTool = builtinModelTools.find(
|
||||
(tool) => tool.name === 'workspace_apply_patch'
|
||||
)!
|
||||
const webSearchTool = builtinModelTools.find(
|
||||
(tool) => tool.name === 'web_search'
|
||||
)!
|
||||
@@ -117,26 +122,48 @@ const workspacePathSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(4_096)
|
||||
.refine((value) => !isAbsolute(value), '路径必须相对于工作区')
|
||||
.refine((value) => !value.includes('\0'), '路径包含无效字符')
|
||||
|
||||
const readInputSchema = z
|
||||
.object({
|
||||
path: workspacePathSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
const listInputSchema = z
|
||||
.object({
|
||||
path: z.string().max(4_096).default('.')
|
||||
})
|
||||
.strict()
|
||||
|
||||
const writeInputSchema = z
|
||||
.object({
|
||||
path: workspacePathSchema,
|
||||
content: z.string().max(MAX_WRITE_BYTES)
|
||||
offset: z.number().int().min(1).default(1),
|
||||
limit: z.number().int().min(1).default(200),
|
||||
offsetBytes: z.number().int().min(0).optional()
|
||||
})
|
||||
.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)
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -264,6 +291,7 @@ export type ModelSubagentBridge = {
|
||||
export type ModelToolProviderProgrammingOptions = {
|
||||
processService?: DirectModelProcessService
|
||||
subagentService?: DirectModelSubagentService<ModelSubagentRequestContext>
|
||||
ripgrepExecutablePath?: string
|
||||
}
|
||||
|
||||
export function createDirectModelSubagentCallId(
|
||||
@@ -757,17 +785,69 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
}
|
||||
|
||||
private getBuiltinTools(): ModelToolDefinition[] {
|
||||
return [
|
||||
const tools: ModelToolDefinition[] = [
|
||||
{
|
||||
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
|
||||
},
|
||||
source: 'builtin'
|
||||
},
|
||||
{
|
||||
name: workspaceReadTextTool.name,
|
||||
displayName: workspaceReadTextTool.displayName,
|
||||
description: workspaceReadTextTool.description,
|
||||
description:
|
||||
'Read any size UTF-8 workspace file. Use offset and limit for line pages, or offsetBytes to continue raw byte pages including very long lines.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: '相对于当前工作区的文件路径'
|
||||
},
|
||||
offset: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
default: 1,
|
||||
description: '从第几行开始读取,行号从 1 开始'
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
default: 200,
|
||||
description: '最多返回多少行'
|
||||
},
|
||||
offsetBytes: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: '可选原始字节起点,用于连续读取超长行'
|
||||
}
|
||||
},
|
||||
required: ['path'],
|
||||
@@ -776,43 +856,38 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
source: 'builtin'
|
||||
},
|
||||
{
|
||||
name: workspaceListDirectoryTool.name,
|
||||
displayName: workspaceListDirectoryTool.displayName,
|
||||
description: workspaceListDirectoryTool.description,
|
||||
name: workspaceApplyPatchTool.name,
|
||||
displayName: workspaceApplyPatchTool.displayName,
|
||||
description:
|
||||
'Apply one *** Begin Patch block to add, update, or delete UTF-8 files in the workspace. Update hunks use @@ and lines prefixed with space, +, or -. Validate context before applying.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
patch: {
|
||||
type: 'string',
|
||||
description: '相对于当前工作区的目录路径,默认为 .'
|
||||
description: 'apply_patch 格式的完整补丁文本'
|
||||
}
|
||||
},
|
||||
additionalProperties: false
|
||||
},
|
||||
source: 'builtin'
|
||||
},
|
||||
{
|
||||
name: workspaceWriteTextTool.name,
|
||||
displayName: workspaceWriteTextTool.displayName,
|
||||
description: workspaceWriteTextTool.description,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: '相对于当前工作区的文件路径'
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: '要写入的完整 UTF-8 文本'
|
||||
}
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
required: ['patch'],
|
||||
additionalProperties: false
|
||||
},
|
||||
source: 'builtin'
|
||||
}
|
||||
]
|
||||
return this.programming.ripgrepExecutablePath
|
||||
? tools
|
||||
: tools.filter((tool) => tool.name !== 'workspace_rg')
|
||||
}
|
||||
|
||||
private async getWorkspaceTools(
|
||||
signal: AbortSignal
|
||||
): Promise<ModelToolDefinition[]> {
|
||||
signal.throwIfAborted()
|
||||
const tools = this.getBuiltinTools()
|
||||
const identity = await this.workspaceAccess.getIdentity()
|
||||
return identity.kind === 'local'
|
||||
? tools
|
||||
: tools.filter((tool) => tool.name === 'workspace_read_text')
|
||||
}
|
||||
|
||||
private async getProcessTool(
|
||||
@@ -1139,14 +1214,22 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
? this.getWebSearchDefinitions()
|
||||
: []
|
||||
const subagentTools = this.getSubagentTool(context)
|
||||
const workspaceTools = await this.getWorkspaceTools(signal)
|
||||
if (context.workMode !== 'execute') {
|
||||
return [...webTools, ...subagentTools, ...scopedTools]
|
||||
return [
|
||||
...workspaceTools.filter(
|
||||
(tool) => builtinModelToolAccessByName.get(tool.name) === 'read'
|
||||
),
|
||||
...webTools,
|
||||
...subagentTools,
|
||||
...scopedTools
|
||||
]
|
||||
}
|
||||
const processTools = await this.getProcessTool(context, signal)
|
||||
const bindings = await this.getMcpBindings(signal, true)
|
||||
const browserTools = this.getBrowserTools(context)
|
||||
return [
|
||||
...this.getBuiltinTools(),
|
||||
...workspaceTools,
|
||||
...processTools,
|
||||
...subagentTools,
|
||||
...(browserTools?.listTools() ?? []),
|
||||
@@ -1548,60 +1631,30 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (name === 'workspace_rg') {
|
||||
if (!this.programming.ripgrepExecutablePath) {
|
||||
throw new Error('GoodBuddy 内置 ripgrep 不可用')
|
||||
}
|
||||
const input = ripgrepInputSchema.parse(argumentsValue)
|
||||
return createTextToolResult(
|
||||
await searchWorkspaceWithRipgrep(
|
||||
this.programming.ripgrepExecutablePath,
|
||||
input,
|
||||
this.workspaceAccess,
|
||||
signal
|
||||
)
|
||||
)
|
||||
}
|
||||
if (name === 'workspace_read_text') {
|
||||
const input = readInputSchema.parse(argumentsValue)
|
||||
return createTextToolResult(
|
||||
(
|
||||
await this.workspaceAccess.readText({
|
||||
path: input.path,
|
||||
maximumBytes: MAX_READ_BYTES,
|
||||
tooLargeMessage: '工作区文本文件超过 256KB 安全限制',
|
||||
invalidUtf8Message: '工作区读取目标不是有效 UTF-8 文本',
|
||||
signal
|
||||
})
|
||||
).content
|
||||
await readWorkspaceLines(this.workspaceAccess, input, signal)
|
||||
)
|
||||
}
|
||||
if (name === 'workspace_list_directory') {
|
||||
const input = listInputSchema.parse(argumentsValue)
|
||||
const listing = await this.workspaceAccess.listDirectory({
|
||||
path: input.path,
|
||||
maximumEntries: 200,
|
||||
includeGit: true,
|
||||
includeOther: true,
|
||||
signal
|
||||
})
|
||||
if (name === 'workspace_apply_patch') {
|
||||
const input = applyPatchInputSchema.parse(argumentsValue)
|
||||
return createTextToolResult(
|
||||
boundedJson(
|
||||
{
|
||||
entries: listing.entries
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.type
|
||||
})),
|
||||
truncated: listing.truncated
|
||||
},
|
||||
'工作区目录结果无法序列化'
|
||||
)
|
||||
)
|
||||
}
|
||||
if (name === 'workspace_write_text') {
|
||||
const input = writeInputSchema.parse(argumentsValue)
|
||||
if (Buffer.byteLength(input.content) > MAX_WRITE_BYTES) {
|
||||
throw new Error('写入内容超过 512KB 安全限制')
|
||||
}
|
||||
const written = await this.workspaceAccess.writeTextAtomic({
|
||||
path: input.path,
|
||||
content: input.content,
|
||||
maximumBytes: MAX_WRITE_BYTES,
|
||||
signal
|
||||
})
|
||||
return createTextToolResult(
|
||||
boundedJson(
|
||||
written,
|
||||
'工作区写入结果无法序列化'
|
||||
)
|
||||
await applyWorkspacePatch(input.patch, this.workspaceAccess, signal)
|
||||
)
|
||||
}
|
||||
if (name === 'process_execute') {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { LocalWorkspaceAccess } from '../workspace'
|
||||
import { applyWorkspacePatch } from './workspace-apply-patch'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((path) =>
|
||||
rm(path, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
async function createWorkspace(): Promise<string> {
|
||||
const path = await mkdtemp(join(tmpdir(), 'goodbuddy-apply-patch-'))
|
||||
temporaryDirectories.push(path)
|
||||
return path
|
||||
}
|
||||
|
||||
describe('workspace apply patch', () => {
|
||||
it('validates and applies add, update, and delete operations', async () => {
|
||||
const root = await createWorkspace()
|
||||
await mkdir(join(root, 'src'))
|
||||
await writeFile(join(root, 'src', 'app.ts'), 'const old = 1\n', 'utf8')
|
||||
await writeFile(join(root, 'obsolete.txt'), 'remove me\n', 'utf8')
|
||||
|
||||
const result = await applyWorkspacePatch(
|
||||
[
|
||||
'*** Begin Patch',
|
||||
'*** Update File: src/app.ts',
|
||||
'@@',
|
||||
'-const old = 1',
|
||||
'+const current = 2',
|
||||
'*** Add File: nested/new.ts',
|
||||
'+export const added = true',
|
||||
'*** Delete File: obsolete.txt',
|
||||
'*** End Patch'
|
||||
].join('\n'),
|
||||
new LocalWorkspaceAccess(root),
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(result).toContain('3 file(s)')
|
||||
await expect(readFile(join(root, 'src', 'app.ts'), 'utf8')).resolves.toBe(
|
||||
'const current = 2\n'
|
||||
)
|
||||
await expect(readFile(join(root, 'nested', 'new.ts'), 'utf8')).resolves.toBe(
|
||||
'export const added = true'
|
||||
)
|
||||
await expect(readFile(join(root, 'obsolete.txt'), 'utf8')).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not modify any file when validation fails', async () => {
|
||||
const root = await createWorkspace()
|
||||
await writeFile(join(root, 'first.txt'), 'original\n', 'utf8')
|
||||
|
||||
await expect(
|
||||
applyWorkspacePatch(
|
||||
[
|
||||
'*** Begin Patch',
|
||||
'*** Update File: first.txt',
|
||||
'@@',
|
||||
'-original',
|
||||
'+changed',
|
||||
'*** Update File: missing.txt',
|
||||
'@@',
|
||||
'-missing',
|
||||
'+changed',
|
||||
'*** End Patch'
|
||||
].join('\n'),
|
||||
new LocalWorkspaceAccess(root),
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toThrow('补丁目标不存在')
|
||||
await expect(readFile(join(root, 'first.txt'), 'utf8')).resolves.toBe(
|
||||
'original\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects traversal before creating files', async () => {
|
||||
const root = await createWorkspace()
|
||||
await expect(
|
||||
applyWorkspacePatch(
|
||||
'*** Begin Patch\n*** Add File: ../outside.txt\n+blocked\n*** End Patch',
|
||||
new LocalWorkspaceAccess(root),
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toThrow('不能超出工作区')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,338 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
stat
|
||||
} from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative, sep, win32 } from 'node:path'
|
||||
import type { WorkspaceAccess } from '../workspace'
|
||||
|
||||
type PatchOperation =
|
||||
| { kind: 'add'; path: string; content: string }
|
||||
| { kind: 'update'; path: string; hunks: PatchHunk[] }
|
||||
| { kind: 'delete'; path: string }
|
||||
|
||||
type PatchHunk = {
|
||||
section?: string
|
||||
lines: Array<{ kind: 'context' | 'add' | 'delete'; text: string }>
|
||||
}
|
||||
|
||||
type PreparedOperation =
|
||||
| { kind: 'write'; path: string; target: string; content: string; mode: number }
|
||||
| { kind: 'delete'; path: string; target: string }
|
||||
|
||||
const MAXIMUM_RESULT_BYTES = 200 * 1024
|
||||
|
||||
function normalizedPatchPath(value: string): string {
|
||||
const path = value.trim().replaceAll('\\', '/')
|
||||
if (
|
||||
!path ||
|
||||
isAbsolute(path) ||
|
||||
win32.isAbsolute(path) ||
|
||||
path.includes('\0')
|
||||
) {
|
||||
throw new Error(`补丁路径无效:${value}`)
|
||||
}
|
||||
const segments = path.split('/')
|
||||
if (segments.some((segment) => !segment || segment === '.' || segment === '..')) {
|
||||
throw new Error(`补丁路径不能超出工作区:${value}`)
|
||||
}
|
||||
return segments.join('/')
|
||||
}
|
||||
|
||||
function parseUpdateHunks(lines: string[], path: string): PatchHunk[] {
|
||||
const hunks: PatchHunk[] = []
|
||||
let current: PatchHunk | undefined
|
||||
for (const line of lines) {
|
||||
if (line === '@@' || line.startsWith('@@ ')) {
|
||||
current = {
|
||||
...(line.length > 3 ? { section: line.slice(3) } : {}),
|
||||
lines: []
|
||||
}
|
||||
hunks.push(current)
|
||||
continue
|
||||
}
|
||||
if (!current) {
|
||||
if (!line) continue
|
||||
throw new Error(`更新文件 ${path} 的补丁缺少 @@ hunk`)
|
||||
}
|
||||
const prefix = line[0]
|
||||
if (prefix !== ' ' && prefix !== '+' && prefix !== '-') {
|
||||
throw new Error(`更新文件 ${path} 包含无效补丁行`)
|
||||
}
|
||||
current.lines.push({
|
||||
kind: prefix === '+' ? 'add' : prefix === '-' ? 'delete' : 'context',
|
||||
text: line.slice(1)
|
||||
})
|
||||
}
|
||||
if (hunks.length === 0 || hunks.some((hunk) => hunk.lines.length === 0)) {
|
||||
throw new Error(`更新文件 ${path} 的补丁 hunk 为空`)
|
||||
}
|
||||
return hunks
|
||||
}
|
||||
|
||||
export function parseWorkspacePatch(patch: string): PatchOperation[] {
|
||||
const lines = patch.replaceAll('\r\n', '\n').split('\n')
|
||||
if (lines.shift() !== '*** Begin Patch') {
|
||||
throw new Error('补丁必须以 *** Begin Patch 开始')
|
||||
}
|
||||
const operations: PatchOperation[] = []
|
||||
while (lines.length > 0) {
|
||||
const header = lines.shift()!
|
||||
if (header === '*** End Patch') {
|
||||
if (lines.some(Boolean)) {
|
||||
throw new Error('*** End Patch 后不能包含其他内容')
|
||||
}
|
||||
if (operations.length === 0) {
|
||||
throw new Error('补丁不包含文件操作')
|
||||
}
|
||||
return operations
|
||||
}
|
||||
const match = header.match(/^\*\*\* (Add|Update|Delete) File: (.+)$/u)
|
||||
if (!match) {
|
||||
throw new Error(`无效补丁文件头:${header}`)
|
||||
}
|
||||
const path = normalizedPatchPath(match[2]!)
|
||||
const body: string[] = []
|
||||
while (lines.length > 0 && !lines[0]!.startsWith('*** ')) {
|
||||
body.push(lines.shift()!)
|
||||
}
|
||||
if (match[1] === 'Add') {
|
||||
if (body.some((line) => !line.startsWith('+'))) {
|
||||
throw new Error(`新增文件 ${path} 的每一行都必须以 + 开始`)
|
||||
}
|
||||
operations.push({
|
||||
kind: 'add',
|
||||
path,
|
||||
content: body.map((line) => line.slice(1)).join('\n')
|
||||
})
|
||||
} else if (match[1] === 'Update') {
|
||||
operations.push({ kind: 'update', path, hunks: parseUpdateHunks(body, path) })
|
||||
} else {
|
||||
if (body.some(Boolean)) {
|
||||
throw new Error(`删除文件 ${path} 不能包含补丁正文`)
|
||||
}
|
||||
operations.push({ kind: 'delete', path })
|
||||
}
|
||||
}
|
||||
throw new Error('补丁缺少 *** End Patch')
|
||||
}
|
||||
|
||||
function sequenceMatches(
|
||||
source: string[],
|
||||
expected: string[],
|
||||
index: number,
|
||||
relaxed: boolean
|
||||
): boolean {
|
||||
return expected.every((line, offset) =>
|
||||
relaxed
|
||||
? source[index + offset]?.trimEnd() === line.trimEnd()
|
||||
: source[index + offset] === line
|
||||
)
|
||||
}
|
||||
|
||||
function findSequence(
|
||||
source: string[],
|
||||
expected: string[],
|
||||
start: number
|
||||
): number {
|
||||
if (expected.length === 0) return start
|
||||
for (const relaxed of [false, true]) {
|
||||
for (let index = start; index <= source.length - expected.length; index += 1) {
|
||||
if (sequenceMatches(source, expected, index, relaxed)) return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
export function applyUpdateHunks(
|
||||
content: string,
|
||||
hunks: PatchHunk[],
|
||||
path: string
|
||||
): string {
|
||||
const newline = content.includes('\r\n') ? '\r\n' : '\n'
|
||||
let source = content.replaceAll('\r\n', '\n').split('\n')
|
||||
let cursor = 0
|
||||
for (const hunk of hunks) {
|
||||
if (hunk.section) {
|
||||
const sectionIndex = source.findIndex(
|
||||
(line, index) => index >= cursor && line.includes(hunk.section!)
|
||||
)
|
||||
if (sectionIndex < 0) {
|
||||
throw new Error(`无法在 ${path} 中找到补丁区段:${hunk.section}`)
|
||||
}
|
||||
cursor = sectionIndex
|
||||
}
|
||||
const expected = hunk.lines
|
||||
.filter((line) => line.kind !== 'add')
|
||||
.map((line) => line.text)
|
||||
const replacement = hunk.lines
|
||||
.filter((line) => line.kind !== 'delete')
|
||||
.map((line) => line.text)
|
||||
const index = findSequence(source, expected, cursor)
|
||||
if (index < 0) {
|
||||
throw new Error(`无法在 ${path} 中匹配补丁上下文`)
|
||||
}
|
||||
source = [
|
||||
...source.slice(0, index),
|
||||
...replacement,
|
||||
...source.slice(index + expected.length)
|
||||
]
|
||||
cursor = index + replacement.length
|
||||
}
|
||||
return source.join(newline)
|
||||
}
|
||||
|
||||
function isInside(root: string, target: string): boolean {
|
||||
const value = relative(root, target)
|
||||
return value === '' || (!value.startsWith(`..${sep}`) && value !== '..' && !isAbsolute(value))
|
||||
}
|
||||
|
||||
async function resolveTarget(
|
||||
root: string,
|
||||
patchPath: string,
|
||||
createParents: boolean
|
||||
): Promise<string> {
|
||||
const segments = patchPath.split('/')
|
||||
let current = root
|
||||
for (const [index, segment] of segments.slice(0, -1).entries()) {
|
||||
current = join(current, segment)
|
||||
const metadata = await lstat(current).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return undefined
|
||||
throw error
|
||||
})
|
||||
if (!metadata) {
|
||||
if (!createParents) throw new Error(`补丁父目录不存在:${patchPath}`)
|
||||
return join(current, ...segments.slice(index + 1))
|
||||
} else if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new Error(`补丁路径包含非普通目录:${patchPath}`)
|
||||
}
|
||||
}
|
||||
const canonicalParent = await realpath(join(root, ...segments.slice(0, -1)))
|
||||
if (!isInside(root, canonicalParent)) {
|
||||
throw new Error(`补丁路径不能通过符号链接超出工作区:${patchPath}`)
|
||||
}
|
||||
return join(canonicalParent, segments.at(-1)!)
|
||||
}
|
||||
|
||||
async function readUtf8(path: string): Promise<string> {
|
||||
const bytes = await readFile(path)
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
||||
} catch (error) {
|
||||
throw new Error('补丁只能修改有效 UTF-8 文本文件', { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAtomic(target: string, content: string, mode: number): Promise<void> {
|
||||
const temporary = `${target}.${randomUUID()}.tmp`
|
||||
const handle = await open(temporary, 'wx', mode)
|
||||
try {
|
||||
try {
|
||||
await handle.writeFile(content, 'utf8')
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
await rename(temporary, target)
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true }).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyWorkspacePatch(
|
||||
patch: string,
|
||||
workspace: WorkspaceAccess,
|
||||
signal: AbortSignal
|
||||
): Promise<string> {
|
||||
signal.throwIfAborted()
|
||||
const identity = await workspace.getIdentity()
|
||||
if (identity.kind !== 'local') {
|
||||
throw new Error('工作区补丁当前只支持本机工作区')
|
||||
}
|
||||
const root = await realpath(identity.canonicalDisplayPath)
|
||||
const operations = parseWorkspacePatch(patch)
|
||||
const seen = new Set<string>()
|
||||
const prepared: PreparedOperation[] = []
|
||||
for (const operation of operations) {
|
||||
signal.throwIfAborted()
|
||||
const targetKey = process.platform === 'win32' ? operation.path.toLowerCase() : operation.path
|
||||
if (seen.has(targetKey)) {
|
||||
throw new Error(`同一补丁不能重复操作文件:${operation.path}`)
|
||||
}
|
||||
seen.add(targetKey)
|
||||
const target = await resolveTarget(root, operation.path, operation.kind === 'add')
|
||||
const metadata = await lstat(target).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return undefined
|
||||
throw error
|
||||
})
|
||||
if (metadata?.isSymbolicLink() || (metadata && !metadata.isFile())) {
|
||||
throw new Error(`补丁目标不是普通文件:${operation.path}`)
|
||||
}
|
||||
if (operation.kind === 'add') {
|
||||
if (metadata) throw new Error(`新增文件已经存在:${operation.path}`)
|
||||
prepared.push({
|
||||
kind: 'write',
|
||||
path: operation.path,
|
||||
target,
|
||||
content: operation.content,
|
||||
mode: 0o600
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (!metadata) throw new Error(`补丁目标不存在:${operation.path}`)
|
||||
if (operation.kind === 'delete') {
|
||||
prepared.push({ kind: 'delete', path: operation.path, target })
|
||||
continue
|
||||
}
|
||||
const content = applyUpdateHunks(
|
||||
await readUtf8(target),
|
||||
operation.hunks,
|
||||
operation.path
|
||||
)
|
||||
prepared.push({
|
||||
kind: 'write',
|
||||
path: operation.path,
|
||||
target,
|
||||
content,
|
||||
mode: (await stat(target)).mode & 0o777
|
||||
})
|
||||
}
|
||||
|
||||
const applied: string[] = []
|
||||
try {
|
||||
for (const operation of prepared) {
|
||||
signal.throwIfAborted()
|
||||
if (operation.kind === 'delete') {
|
||||
await rm(operation.target)
|
||||
} else {
|
||||
await mkdir(dirname(operation.target), { recursive: true, mode: 0o700 })
|
||||
await writeAtomic(operation.target, operation.content, operation.mode)
|
||||
}
|
||||
applied.push(operation.path)
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`补丁仅完成 ${applied.length}/${prepared.length} 个文件:${applied.join(', ') || '无'}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
const header = `Applied patch to ${applied.length} file(s):`
|
||||
const result = [header]
|
||||
let resultBytes = Buffer.byteLength(header) + 1
|
||||
for (const path of applied) {
|
||||
const bytes = Buffer.byteLength(path) + 1
|
||||
if (resultBytes + bytes > MAXIMUM_RESULT_BYTES) {
|
||||
result.push(`...[${applied.length - result.length + 1} more files]`)
|
||||
break
|
||||
}
|
||||
result.push(path)
|
||||
resultBytes += bytes
|
||||
}
|
||||
return result.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { LocalWorkspaceAccess } from '../workspace'
|
||||
import { readWorkspaceLines } from './workspace-read-lines'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((path) =>
|
||||
rm(path, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
describe('workspace line reader', () => {
|
||||
it('reads a selected line page without adding a line after the final newline', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-read-lines-'))
|
||||
temporaryDirectories.push(root)
|
||||
await writeFile(join(root, 'sample.txt'), 'one\ntwo\nthree\nfour\n', 'utf8')
|
||||
|
||||
const result = await readWorkspaceLines(
|
||||
new LocalWorkspaceAccess(root),
|
||||
{ path: 'sample.txt', offset: 2, limit: 2 },
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(result).toBe('2: two\n3: three\n[lines 2-3; more available]')
|
||||
const last = await readWorkspaceLines(
|
||||
new LocalWorkspaceAccess(root),
|
||||
{ path: 'sample.txt', offset: 4, limit: 10 },
|
||||
new AbortController().signal
|
||||
)
|
||||
expect(last).toContain('4: four')
|
||||
expect(last).not.toContain('5:')
|
||||
})
|
||||
|
||||
it('bounds a line that spans multiple byte pages', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-read-lines-'))
|
||||
temporaryDirectories.push(root)
|
||||
await writeFile(join(root, 'long.txt'), `${'x'.repeat(300_000)}\nnext`, 'utf8')
|
||||
|
||||
const result = await readWorkspaceLines(
|
||||
new LocalWorkspaceAccess(root),
|
||||
{ path: 'long.txt', offset: 1, limit: 2 },
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(result).toContain('1: ')
|
||||
expect(result).toContain('...[line truncated]')
|
||||
expect(result).toContain('2: next')
|
||||
expect(Buffer.byteLength(result)).toBeLessThan(20_000)
|
||||
|
||||
const rawPage = await readWorkspaceLines(
|
||||
new LocalWorkspaceAccess(root),
|
||||
{ path: 'long.txt', offset: 1, limit: 1, offsetBytes: 0 },
|
||||
new AbortController().signal
|
||||
)
|
||||
expect(rawPage).toContain('continue with offsetBytes=')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { WorkspaceAccess } from '../workspace'
|
||||
|
||||
const READ_CHUNK_BYTES = 128 * 1024
|
||||
const MAXIMUM_RESULT_BYTES = 240 * 1024
|
||||
const MAXIMUM_LINE_CHARACTERS = 8_000
|
||||
|
||||
export async function readWorkspaceLines(
|
||||
workspace: WorkspaceAccess,
|
||||
input: { path: string; offset: number; limit: number; offsetBytes?: number },
|
||||
signal: AbortSignal
|
||||
): Promise<string> {
|
||||
if (input.offsetBytes !== undefined) {
|
||||
const page = await workspace.readText({
|
||||
path: input.path,
|
||||
offsetBytes: input.offsetBytes,
|
||||
maximumBytes: MAXIMUM_RESULT_BYTES,
|
||||
allowTruncated: true,
|
||||
invalidUtf8Message: '工作区读取目标不是有效 UTF-8 文本',
|
||||
signal
|
||||
})
|
||||
const nextOffset = page.offsetBytes + page.bytesRead
|
||||
const footer = page.truncated
|
||||
? `[bytes ${page.offsetBytes}-${nextOffset}; continue with offsetBytes=${nextOffset}]`
|
||||
: `[bytes ${page.offsetBytes}-${nextOffset}; end of file; ${page.size} bytes]`
|
||||
return `${page.content}\n${footer}`
|
||||
}
|
||||
let offsetBytes = 0
|
||||
let currentLine = 1
|
||||
let pending = ''
|
||||
let pendingTruncated = false
|
||||
let totalBytes: number | undefined
|
||||
let moreAvailable = false
|
||||
let resultBytes = 0
|
||||
const output: string[] = []
|
||||
|
||||
while (true) {
|
||||
signal.throwIfAborted()
|
||||
const page = await workspace.readText({
|
||||
path: input.path,
|
||||
offsetBytes,
|
||||
maximumBytes: READ_CHUNK_BYTES,
|
||||
allowTruncated: true,
|
||||
invalidUtf8Message: '工作区读取目标不是有效 UTF-8 文本',
|
||||
signal
|
||||
})
|
||||
totalBytes = page.size
|
||||
offsetBytes += page.bytesRead
|
||||
const carriedLineTruncated: boolean = pendingTruncated
|
||||
const combined = pending + page.content
|
||||
const lines = combined.split('\n')
|
||||
pending = page.truncated ? lines.pop() ?? '' : ''
|
||||
pendingTruncated =
|
||||
page.truncated &&
|
||||
(carriedLineTruncated || pending.length > MAXIMUM_LINE_CHARACTERS)
|
||||
if (pending.length > MAXIMUM_LINE_CHARACTERS) {
|
||||
pending = pending.slice(0, MAXIMUM_LINE_CHARACTERS)
|
||||
}
|
||||
if (!page.truncated && lines.at(-1) === '') lines.pop()
|
||||
|
||||
for (const [lineIndex, rawLine] of lines.entries()) {
|
||||
if (currentLine >= input.offset) {
|
||||
if (output.length >= input.limit) {
|
||||
moreAvailable = true
|
||||
break
|
||||
}
|
||||
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
|
||||
const lineTruncated =
|
||||
line.length > MAXIMUM_LINE_CHARACTERS ||
|
||||
(lineIndex === 0 && carriedLineTruncated)
|
||||
const preview = `${line.slice(0, MAXIMUM_LINE_CHARACTERS)}${
|
||||
lineTruncated ? '...[line truncated]' : ''
|
||||
}`
|
||||
const formatted = `${currentLine}: ${preview}`
|
||||
const bytes = Buffer.byteLength(formatted) + 1
|
||||
if (resultBytes + bytes > MAXIMUM_RESULT_BYTES) {
|
||||
moreAvailable = true
|
||||
break
|
||||
}
|
||||
output.push(formatted)
|
||||
resultBytes += bytes
|
||||
}
|
||||
currentLine += 1
|
||||
}
|
||||
if (moreAvailable) break
|
||||
if (!page.truncated) {
|
||||
if (pending && currentLine >= input.offset && output.length < input.limit) {
|
||||
const formatted = `${currentLine}: ${pending.slice(0, MAXIMUM_LINE_CHARACTERS)}`
|
||||
if (resultBytes + Buffer.byteLength(formatted) + 1 <= MAXIMUM_RESULT_BYTES) {
|
||||
output.push(formatted)
|
||||
} else {
|
||||
moreAvailable = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
if (page.bytesRead === 0) {
|
||||
throw new Error('工作区读取分页无法继续')
|
||||
}
|
||||
}
|
||||
|
||||
if (output.length === 0) {
|
||||
return `[no lines at offset ${input.offset}; ${totalBytes ?? 0} bytes]`
|
||||
}
|
||||
const lastLine = Number.parseInt(output.at(-1)!, 10)
|
||||
const footer = moreAvailable
|
||||
? `[lines ${input.offset}-${lastLine}; more available]`
|
||||
: `[lines ${input.offset}-${lastLine}; end of file; ${totalBytes ?? 0} bytes]`
|
||||
return `${output.join('\n')}\n${footer}`
|
||||
}
|
||||
@@ -5255,12 +5255,12 @@ describe('SettingsPanel runtime files', () => {
|
||||
fireEvent.click(
|
||||
within(mcpTabs).getByRole('tab', { name: '直连模型' })
|
||||
)
|
||||
expect(await screen.findByText('文件系统操作')).toBeInTheDocument()
|
||||
expect(await screen.findByText('工作区文件')).toBeInTheDocument()
|
||||
expect(screen.queryByText('内置浏览器')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('联网搜索')).toBeInTheDocument()
|
||||
expect(screen.getByText('编程能力')).toBeInTheDocument()
|
||||
expect(screen.getByText('开发工具')).toBeInTheDocument()
|
||||
const programmingToggle = screen.getByRole('button', {
|
||||
name: '展开工具组 编程能力'
|
||||
name: '展开工具组 开发工具'
|
||||
})
|
||||
fireEvent.click(programmingToggle)
|
||||
expect(screen.getByText('进程执行')).toBeInTheDocument()
|
||||
@@ -5369,12 +5369,12 @@ describe('SettingsPanel runtime files', () => {
|
||||
within(mcpTabs).getByRole('tab', { name: '直连模型' })
|
||||
)
|
||||
const filesystemToggle = screen.getByRole('button', {
|
||||
name: '展开工具组 文件系统操作'
|
||||
name: '展开工具组 工作区文件'
|
||||
})
|
||||
fireEvent.click(filesystemToggle)
|
||||
expect(screen.getByText('搜索工作区')).toBeInTheDocument()
|
||||
expect(screen.getByText('读取工作区文本')).toBeInTheDocument()
|
||||
expect(screen.getByText('列出工作区目录')).toBeInTheDocument()
|
||||
expect(screen.getByText('写入工作区文本')).toBeInTheDocument()
|
||||
expect(screen.getByText('应用工作区补丁')).toBeInTheDocument()
|
||||
expect(screen.queryByText('浏览器导航')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /工具组/u })
|
||||
|
||||
@@ -7,25 +7,26 @@ export type BuiltinModelToolSummary = {
|
||||
}
|
||||
|
||||
export const builtinModelTools = [
|
||||
{
|
||||
name: 'workspace_rg',
|
||||
displayName: '搜索工作区',
|
||||
description:
|
||||
'使用 GoodBuddy 内置 ripgrep 搜索工作区内容或列出文件,返回带路径和行号的紧凑结果。',
|
||||
access: 'read',
|
||||
group: 'filesystem'
|
||||
},
|
||||
{
|
||||
name: 'workspace_read_text',
|
||||
displayName: '读取工作区文本',
|
||||
description: '读取当前工作区内不超过 256KB 的 UTF-8 文本文件。',
|
||||
description: '按行分页读取当前工作区内的 UTF-8 文本文件。',
|
||||
access: 'read',
|
||||
group: 'filesystem'
|
||||
},
|
||||
{
|
||||
name: 'workspace_list_directory',
|
||||
displayName: '列出工作区目录',
|
||||
description: '列出当前工作区内目录的直属内容,最多返回 200 项。',
|
||||
access: 'read',
|
||||
group: 'filesystem'
|
||||
},
|
||||
{
|
||||
name: 'workspace_write_text',
|
||||
displayName: '写入工作区文本',
|
||||
name: 'workspace_apply_patch',
|
||||
displayName: '应用工作区补丁',
|
||||
description:
|
||||
'在当前工作区内新建或覆盖不超过 512KB 的 UTF-8 文本文件,父目录必须已存在。',
|
||||
'使用 apply_patch 格式在当前工作区内新增、修改或删除 UTF-8 文本文件。',
|
||||
access: 'write',
|
||||
group: 'filesystem'
|
||||
},
|
||||
@@ -115,9 +116,9 @@ export const builtinModelTools = [
|
||||
export const builtinModelToolGroups = [
|
||||
{
|
||||
id: 'filesystem',
|
||||
name: '文件系统操作',
|
||||
name: '工作区文件',
|
||||
description:
|
||||
'在 Execute 模式下读取、列出或写入当前工作区范围内的文件。',
|
||||
'搜索和分页读取工作区文件;Execute 模式还可通过补丁新增、修改或删除文件。',
|
||||
tools: builtinModelTools.filter((tool) => tool.group === 'filesystem')
|
||||
},
|
||||
{
|
||||
@@ -129,9 +130,9 @@ export const builtinModelToolGroups = [
|
||||
},
|
||||
{
|
||||
id: 'programming',
|
||||
name: '编程能力',
|
||||
name: '开发工具',
|
||||
description:
|
||||
'直连模型可运行项目命令,或将聚焦任务委派给继承当前模式的 Subagent。',
|
||||
'直连模型可运行项目命令,或将聚焦的开发任务委派给继承当前模式的 Subagent。',
|
||||
tools: builtinModelTools.filter((tool) => tool.group === 'programming')
|
||||
}
|
||||
] as const
|
||||
|
||||
Reference in New Issue
Block a user