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:
mesalogo
2026-09-10 00:45:03 +08:00
parent c1f8ba80ee
commit ec100f8176
28 changed files with 1542 additions and 171 deletions
+4
View File
@@ -247,6 +247,10 @@ otherwise.
user's authorization for all tools, processes, network access, and writable
paths available to the current local or SSH account, including paths outside
the workspace and native subagent work.
- [x] **Efficient direct-model workspace tools**: Direct models use bundled
ripgrep for compact file discovery and content search, read large UTF-8 files
by line, and apply multi-file patches in Execute. Ask can search and read but
cannot patch or run commands; packaged ripgrep does not require a system install.
- [x] **Native Runtime interaction routing**: OpenCode and Continue questions
support choices, yes/no, free-text answers, and skipping through the existing
question card. OpenCode also routes questions from owned child sessions.
+1
View File
@@ -31,6 +31,7 @@
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行、图像生成,以及通过支持 OpenAI 兼容图片编辑接口的服务商进行参考图编辑。编辑使用 multipart 上传附件图片,结果仍只接受经过校验的内联图片,不下载服务商返回的图片 URL。
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
- [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 紧凑地发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并在 Execute 中应用多文件补丁。Ask 可以搜索和读取,但不能应用补丁或运行命令;用户无需另行安装 ripgrep。
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、启动与控制请求时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用,但 OpenCode 配置依赖和按内容摘要生成的 Skill 快照在所有本机项目间全局复用,会话数据、工具输出及请求级 MCP 状态继续隔离。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。托管的本机和远端 OpenCode 跳过界面未使用的自动 Git 快照,避免同步差异计算拖住会话,文件工具、子代理与工作区 Git 差异保留;Continue 持续排空不用展示的宿主 stdout,防止控制台输出填满管道。这些调整随新启动的 Runtime 生效,不改写外部 OpenCode Server 配置。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node 和 OpenCode 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。macOS 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证;这不等于新版本已经发布。详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
- [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。
+7
View File
@@ -141,6 +141,13 @@ function portableRequiredPaths(directory) {
'opencode',
'opencode.exe'
),
join(
directory,
'resources',
'runtimes',
'opencode',
'rg.exe'
),
join(
directory,
'resources',
+17 -4
View File
@@ -94,6 +94,8 @@ const portableRequiredFiles = [
'resources/tray-icon.png',
'resources/tool-environment/managed-python-artifacts.json',
'resources/runtimes/opencode/opencode.exe',
'resources/runtimes/opencode/rg.exe',
'resources/licenses/vscode-ripgrep-MIT.txt',
'resources/runtimes/opencode-config/node_modules/@opencode-ai/plugin/package.json',
'resources/runtimes/opencode-config/node_modules/@opencode-ai/plugin/dist/index.js',
'resources/runtimes/continue/package.json',
@@ -128,7 +130,8 @@ const platformDefinitions = {
supportedFormats: ['nsis', 'portable'],
unpackedPattern: /^win(?:-.+)?-unpacked$/u,
executable: [`${productName}.exe`],
runtimeExecutable: 'opencode.exe'
runtimeExecutable: 'opencode.exe',
ripgrepExecutable: 'rg.exe'
},
macos: {
builderFlag: '--mac',
@@ -141,7 +144,8 @@ const platformDefinitions = {
'MacOS',
productName
],
runtimeExecutable: 'opencode'
runtimeExecutable: 'opencode',
ripgrepExecutable: 'rg'
},
linux: {
builderFlag: '--linux',
@@ -149,7 +153,8 @@ const platformDefinitions = {
supportedFormats: ['AppImage', 'deb', 'rpm'],
unpackedPattern: /^linux(?:-.+)?-unpacked$/u,
executable: [packageJson.name],
runtimeExecutable: 'opencode'
runtimeExecutable: 'opencode',
ripgrepExecutable: 'rg'
}
}
const formatExtensions = {
@@ -1070,6 +1075,12 @@ function verifyUnpackedOutput(directory, options) {
'opencode',
definition.runtimeExecutable
)
const ripgrepExecutable = join(
resources,
'runtimes',
'opencode',
definition.ripgrepExecutable
)
assertFile(applicationExecutable, '应用主程序')
assertFile(join(resources, 'app.asar'), '应用 ASAR')
assertFile(join(resources, 'release-notes.json'), '版本更新说明')
@@ -1094,6 +1105,7 @@ function verifyUnpackedOutput(directory, options) {
'Remote Runtime 锁定清单'
)
assertFile(runtimeExecutable, 'OpenCode Runtime')
assertFile(ripgrepExecutable, 'ripgrep Runtime')
verifyOpenCodeConfig(
resources,
join(root, '.runtime-resources', 'opencode-config')
@@ -1125,7 +1137,8 @@ function verifyUnpackedOutput(directory, options) {
verifyHarnessPackage(resources, options)
for (const [filePath, label] of [
[applicationExecutable, '应用主程序'],
[runtimeExecutable, 'OpenCode Runtime']
[runtimeExecutable, 'OpenCode Runtime'],
[ripgrepExecutable, 'ripgrep Runtime']
]) {
const actualArchitecture = binaryArchitecture(filePath)
if (actualArchitecture !== options.arch) {
+93 -8
View File
@@ -33,28 +33,28 @@ function sha512Integrity(contents) {
return `sha512-${createHash("sha512").update(contents).digest("base64")}`;
}
async function lockedIntegrity(projectDir, packageName) {
async function lockedIntegrity(projectDir, packageName, version) {
const lock = JSON.parse(
await readFile(join(projectDir, "package-lock.json"), "utf8"),
);
const entry = lock.packages?.[`node_modules/${packageName}`];
if (
entry?.version !== opencodeVersion ||
entry?.version !== version ||
typeof entry.integrity !== "string"
) {
throw new Error(
`Missing locked ${packageName}@${opencodeVersion} integrity`,
`Missing locked ${packageName}@${version} integrity`,
);
}
return entry.integrity;
}
async function downloadPackage(projectDir, packageName, integrity) {
async function downloadPackage(projectDir, packageName, version, integrity) {
const cacheDirectory = join(projectDir, ".runtime-resources", "cache");
await mkdir(cacheDirectory, { recursive: true });
const archivePath = join(
cacheDirectory,
`${packageName}-${opencodeVersion}.tgz`,
`${packageName.replaceAll("/", "-").replaceAll("@", "")}-${version}.tgz`,
);
if (existsSync(archivePath)) {
const cached = await readFile(archivePath);
@@ -70,7 +70,7 @@ async function downloadPackage(projectDir, packageName, integrity) {
[
...npm.prefixArgs,
"pack",
`${packageName}@${opencodeVersion}`,
`${packageName}@${version}`,
"--ignore-scripts",
"--json",
"--pack-destination",
@@ -108,6 +108,68 @@ async function downloadPackage(projectDir, packageName, integrity) {
return archivePath;
}
async function prepareRipgrep(
projectDir,
targetDirectory,
platform,
architecture,
version,
) {
const packageName = `@vscode/ripgrep-${platform}-${architecture}`;
const integrity = await lockedIntegrity(projectDir, packageName, version);
const executable = platform === "win32" ? "rg.exe" : "rg";
const preparedPath = join(targetDirectory, executable);
const readyPath = join(targetDirectory, ".ripgrep-ready.json");
const identity = { packageName, version, integrity };
try {
const ready = JSON.parse(await readFile(readyPath, "utf8"));
if (
ready.packageName === identity.packageName &&
ready.version === identity.version &&
ready.integrity === identity.integrity &&
typeof ready.executableSha256 === "string" &&
(platform === "win32" || ((await stat(preparedPath)).mode & 0o111) !== 0) &&
(await sha256File(preparedPath)) === ready.executableSha256
) {
return;
}
} catch {
// Rebuild an incomplete or stale ripgrep cache.
}
const archivePath = await downloadPackage(
projectDir,
packageName,
version,
integrity,
);
const stagingDirectory = `${targetDirectory}.ripgrep-staging-${process.pid}`;
await rm(stagingDirectory, { recursive: true, force: true });
await mkdir(stagingDirectory, { recursive: true });
try {
await tar.x({
file: archivePath,
cwd: stagingDirectory,
strip: 1,
});
const sourcePath = join(stagingDirectory, "bin", executable);
const executableSha256 = await sha256File(sourcePath);
if (platform !== "win32") {
await chmod(sourcePath, 0o755);
}
await mkdir(targetDirectory, { recursive: true });
await rm(preparedPath, { force: true });
await rename(sourcePath, preparedPath);
await writeFile(
readyPath,
JSON.stringify({ ...identity, executableSha256 }),
"utf8",
);
} finally {
await rm(stagingDirectory, { recursive: true, force: true });
}
}
module.exports = async function prepareBundledRuntimes(context) {
const platform = context.electronPlatformName;
const architecture = architectureNames[context.arch];
@@ -126,6 +188,10 @@ module.exports = async function prepareBundledRuntimes(context) {
const projectPackage = JSON.parse(
await readFile(join(projectDir, "package.json"), "utf8"),
);
const ripgrepVersion = projectPackage.dependencies["@vscode/ripgrep"];
if (typeof ripgrepVersion !== "string" || !/^\d+\.\d+\.\d+$/u.test(ripgrepVersion)) {
throw new Error("Missing exact @vscode/ripgrep dependency version");
}
await writeFile(
join(projectDir, "out", "main", "package.json"),
`${JSON.stringify(
@@ -140,7 +206,7 @@ module.exports = async function prepareBundledRuntimes(context) {
)}\n`,
"utf8",
);
const integrity = await lockedIntegrity(projectDir, packageName);
const integrity = await lockedIntegrity(projectDir, packageName, opencodeVersion);
const targetDirectory = join(projectDir, ".runtime-resources", architecture);
const readyPath = join(targetDirectory, ".ready.json");
const executable = platform === "win32" ? "opencode.exe" : "opencode";
@@ -161,13 +227,25 @@ module.exports = async function prepareBundledRuntimes(context) {
((await stat(preparedPath)).mode & 0o111) !== 0) &&
(await sha256File(preparedPath)) === ready.executableSha256
) {
await prepareRipgrep(
projectDir,
targetDirectory,
platform,
architecture,
ripgrepVersion,
);
return;
}
} catch {
// Rebuild an incomplete or stale runtime cache.
}
const archivePath = await downloadPackage(projectDir, packageName, integrity);
const archivePath = await downloadPackage(
projectDir,
packageName,
opencodeVersion,
integrity,
);
const stagingDirectory = `${targetDirectory}.staging-${process.pid}`;
await rm(stagingDirectory, { recursive: true, force: true });
await mkdir(stagingDirectory, { recursive: true });
@@ -194,4 +272,11 @@ module.exports = async function prepareBundledRuntimes(context) {
} finally {
await rm(stagingDirectory, { recursive: true, force: true });
}
await prepareRipgrep(
projectDir,
targetDirectory,
platform,
architecture,
ripgrepVersion,
);
};
+9 -4
View File
@@ -1,8 +1,9 @@
# 直连模型 Agent 能力
本目录是 GoodBuddy“直连模型 Agent 能力”的唯一文档入口。本功能为直连文本模型补齐
跨平台进程执行和编程 Subagent,使其能够在 Execute 模式完成读取、修改、运行和验证组成的
编程闭环,同时避免向已经拥有原生 Shell 或 Agent 能力的 Runtime 重复注入工具。
高效工作区搜索、分页读取、补丁编辑、跨平台进程执行和编程 Subagent,使其能够完成
“搜索、读取、修改、运行、验证”的开发闭环,同时避免向已经拥有原生 Shell 或 Agent
能力的 Runtime 重复注入工具。
## 文档导航
@@ -21,6 +22,8 @@
| --- | --- |
| 直连模型 | Runtime provider 为 `model`、由 GoodBuddy 直接调用模型协议的文本模型 |
| 进程执行 | GoodBuddy 在当前执行空间启动一个前台 Shell 命令并返回有界结果 |
| 工作区搜索 | 通过安装包内置 ripgrep 搜索内容或列出文件,并返回紧凑结果 |
| 工作区补丁 | 通过 `*** Begin Patch` 格式新增、修改或删除工作区文本文件 |
| 平台 Shell | Windows 上的 PowerShell,macOS/Linux 上的 Bash 或 POSIX Sh |
| 编程 Subagent | 由直连模型临时委派、使用直连模型 Runtime 执行一个有界子任务的执行者 |
| 父请求 | 发起进程调用或 Subagent 委派的当前直连模型请求 |
@@ -32,11 +35,13 @@ Task、Job、Subjob、Run 和 Subagent 的对象关系以
## 功能边界
- `process_execute` 和 `subagent_delegate` 是 GoodBuddy 直连模型内置工具,不是外部 MCP。
- `workspace_rg`、`workspace_read_text`、`workspace_apply_patch`、`process_execute` 和
`subagent_delegate` 是 GoodBuddy 直连模型内置工具,不是外部 MCP。
- 直连文本模型的普通问答、工具轮次和上下文摘要会在尚无可见输出时,对瞬时网络错误或
单次请求超时最多自动重试 3 次。
- OpenCode、Continue 和 DeepSeek Harness 继续使用各自原生执行与委派能力。
- Ask 不允许进程执行;Subagent 若在 Ask 中使用,只能继承 Ask 的只读能力。
- Ask 可搜索和分页读取工作区,但不允许补丁写入或进程执行;Subagent 若在 Ask 中使用,
只能继承 Ask 的只读能力。
- Execute 表示用户授权当前执行空间账号的完整能力,不增加第二套工具审批或权限档位。
- 首版跨平台指桌面本机 Windows、macOS 和 Linux。托管 SSH 项目当前继续使用远端
OpenCode;未来若向远端直连模型开放本工具,命令必须由 Host Agent 执行,绝不能回退到
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 已实施 |
| 版本 | 0.3 |
| 日期 | 2026-09-03 |
| 版本 | 0.4 |
| 日期 | 2026-09-09 |
| 关联 PRD | [PRD](./prd.md) |
| 关联故事 | [User Stories](./user-stories.md) |
@@ -24,6 +24,9 @@
10. OpenCode、Continue 和 DeepSeek Harness 的原生能力不被本功能替换或包装。
11. 直连模型只在尚未显示文本或推理内容时自动重试瞬时网络错误或请求超时,最多重试
3 次;用户取消和已显示的部分输出禁止重放。
12. Ask 可调用 `workspace_rg` 和 `workspace_read_text`,但不能调用
`workspace_apply_patch`;Execute 可调用三者。
13. 工作区搜索固定使用随包 ripgrep 的结构化输出,不把任意 ripgrep 参数转成命令执行。
## 2. 状态维度
@@ -39,13 +42,13 @@
## 3. 工具可见性决策表
| Runtime | 模式 | 执行空间 | `process_execute` | `subagent_delegate` |
| --- | --- | --- | --- | --- |
| `model` | Ask | 本机 | 隐藏 | 提供,只读继承 |
| `model` | Execute | 本机且 Shell 可用 | 提供 | 提供,Execute 继承 |
| `model` | Execute | 本机但 Shell 不可用 | 隐藏并报告诊断 | 提供,但子级同样没有进程工具 |
| `model` | Ask/Execute | SSH,远端后端未实现 | 隐藏 | 当前远端直连模型产品入口不开放 |
| 其他 Runtime | 任意 | 任意 | 隐藏 | 隐藏 |
| Runtime | 模式 | 工作区读/搜索 | 补丁 | `process_execute` | `subagent_delegate` |
| --- | --- | --- | --- | --- | --- |
| `model` | Ask | 提供 | 隐藏 | 隐藏 | 提供,只读继承 |
| `model` | Execute,本机 Shell 可用 | 提供 | 提供 | 提供 | 提供,Execute 继承 |
| `model` | Execute,本机 Shell 不可用 | 提供 | 提供 | 隐藏并报告诊断 | 提供 |
| `model` | SSH,远端后端未实现 | 当前入口不开放 | 当前入口不开放 | 隐藏 | 隐藏 |
| 其他 Runtime | 任意 | 不注入本功能工具 | 不注入 | 不注入 | 不注入 |
工具清单隐藏不是最终授权边界。`callTool` 仍须重新验证 Runtime、工作模式、执行空间和父请求
是否活动,以拒绝旧快照或伪造调用。
+30 -5
View File
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 已实施,跨平台 CI 待验证 |
| 版本 | 0.3 |
| 日期 | 2026-09-03 |
| 版本 | 0.4 |
| 日期 | 2026-09-09 |
| 适用产品 | GoodBuddy 桌面端 |
| 目标平台 | Windows、macOS、Linux,x64 与 arm64 |
| 关联领域 | [Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) |
@@ -22,10 +22,11 @@ Git 或项目脚本,因此无法独立验证结果。
工具,与本功能的 `subagent_delegate` 仍为不同入口。OpenCode、Continue 和 DeepSeek Harness 一般已经提供
Shell、Agent 或 Task 能力,再向它们注入 GoodBuddy 同名工具只会造成重复入口和语义冲突。
本功能只补齐直连模型缺少的基础 Agent 能力:
本功能补齐直连模型缺少的基础 Agent 能力:
1. 一个按平台适配的进程执行接口。
2. 一个单层、继承父请求范围的编程 Subagent 接口。
1. 一组工作区搜索、分页读取和补丁编辑接口。
2. 一个按平台适配的进程执行接口。
3. 一个单层、继承父请求范围的编程 Subagent 接口。
## 2. 产品目标
@@ -34,6 +35,7 @@ Shell、Agent 或 Task 能力,再向它们注入 GoodBuddy 同名工具只会
3. 让直连模型可以把独立工作委派给临时 Subagent,并取得有界、可核验的结果。
4. 保持 Ask 只读、Execute 完整授权、执行空间、取消、有界输出和进程回收语义不变。
5. 不向已有原生执行能力的 Agent Runtime 重复注入 GoodBuddy 进程或 Subagent 工具。
6. 使用安装包内置 ripgrep 提供跨平台一致的快速代码搜索,不依赖用户预装命令。
## 3. 核心产品决策
@@ -76,6 +78,12 @@ Subagent 使用父请求的项目、执行空间、工作模式、直连模型
Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完整授权。进程和 Subagent 不增加
“受控执行”“仅测试”“可信命令”或逐命令风险等级,也不重复弹出第二套确认。
### 3.6 精简工作区工具
直连模型使用 `workspace_rg` 发现文件和搜索内容,使用 `workspace_read_text` 按行分页读取,
使用 `workspace_apply_patch` 新增、修改或删除文本文件。新请求不再注入低效的单层目录列表和
整文件覆盖工具。Ask 提供前两个只读工具;Execute 额外提供补丁工具。
## 4. 功能范围
### 4.1 包含
@@ -88,6 +96,7 @@ Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完
- 直连模型单层 Subagent 委派、排队、运行、部分结果、失败和取消。
- 父子请求的工具、模型用量、活动和成果归属。
- 桌面聊天和选择直连模型的消息通道项目使用相同 Runtime 能力矩阵。
- 安装包按目标平台和架构携带固定版本 ripgrep,并将其提供给工作区搜索和命令 PATH。
### 4.2 不包含
@@ -180,6 +189,19 @@ Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完
- 一旦本次模型调用已经显示文本或推理内容,不自动重放该调用,避免重复回答。
- 非网络类 Provider 错误、协议错误和用户取消不进入自动重试。
### FR-12 高效工作区工具
- `workspace_rg` 同时支持内容搜索和文件列表,遵循项目忽略规则,并以
`path:line:column:text` 紧凑格式返回匹配。
- `workspace_read_text` 接受起始行和行数,大文件通过续读完成,不因超过旧的整文件上限而
完全不可读;超长单行可使用返回的字节位置继续读取。
- `workspace_apply_patch` 支持一个补丁新增、修改和删除多个 UTF-8 文件;执行前验证全部
路径和 hunk,执行失败时准确报告已完成文件。
- 搜索和读取在 Ask/Execute 均可用;补丁只在 Execute 可用,Main 调用边界再次检查模式。
- `workspace_rg` 使用固定参数调用随包 ripgrep,不向模型开放 `--pre` 或任意命令参数。
- 工作区工具不限制文件总大小、补丁总大小、搜索表达式长度、glob 数量或模型请求的结果
数量。单次返回按上下文容量截断或分页;读取结果提供续读位置,搜索结果提示模型缩小查询。
## 6. 产品验收
- [ ] Windows 直连模型 Execute 使用 PowerShell 创建文件、运行测试并读取退出码。
@@ -195,4 +217,7 @@ Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完
- [ ] 子任务不进入 Task Center,不创建独立可导航 Conversation。
- [ ] TLS 建连重置和请求超时在未输出内容时最多自动重试 3 次,成功后继续原请求。
- [ ] 重试等待可取消,已显示部分内容的失败不自动重放。
- [ ] Ask 可使用 ripgrep 搜索和分页读取,但不能应用补丁。
- [ ] Execute 可通过一个补丁新增、修改和删除多个工作区文件,并运行测试验证。
- [ ] Windows、macOS、Linux 的 x64/arm64 安装包均携带对应 ripgrep,且无需系统预装。
- [ ] 全量测试、类型检查、Lint 和生产构建通过。
+25 -3
View File
@@ -6,8 +6,8 @@
| --- | --- |
| 文档类型 | 功能进度 |
| 状态 | 已实施,本机验收完成 |
| 版本 | 0.4 |
| 日期 | 2026-09-05 |
| 版本 | 0.5 |
| 日期 | 2026-09-09 |
| 关联入口 | [直连模型 Agent 能力](./README.md) |
## 当前结论
@@ -56,7 +56,7 @@
- [x] `ModelToolProvider` 注册与调用。
- [x] Ask/Execute、Runtime target 和本机执行空间双重检查。
- [x] DeepSeek Harness、OpenCode 和 Continue 隔离。
- [x] 通用工具活动和“设置 > MCP > 直连模型”编程能力目录。
- [x] 通用工具活动和“设置 > MCP > 直连模型”开发工具目录。
- [x] 普通问答、工具轮次和上下文摘要的瞬时网络错误及请求超时最多自动重试 3 次。
- [x] 递增退避、重试状态、取消传播和已显示输出不重放。
@@ -86,6 +86,28 @@
GoodBuddy 托管 Node/Python 已可用。
- 远端直连模型进程执行不在首版,后续需要独立增加 Agent 进程协议和真机验证。
## 2026-09-09 高效工作区工具
- [x] 新增 `workspace_rg`,以随包 ripgrep 同时提供内容搜索和文件发现;内部解析 JSON,向
模型返回紧凑的路径、行号、列号和预览。
- [x] `workspace_read_text` 改为按行分页读取,大文件不再因旧的整文件 256 KiB 上限完全
不可读。
- [x] 移除搜索表达式、glob 数量、请求结果数、读取行数、补丁大小和目标文件大小的固定上限;
单次输出继续分页或截断,超长单行可通过 `offsetBytes` 续读。
- [x] 新增 `workspace_apply_patch`,支持一个补丁新增、修改和删除多个 UTF-8 文件,并在
修改前完成路径和 hunk 验证。
- [x] Ask 工具清单开放工作区搜索和读取,补丁与进程仍只在 Execute 提供。
- [x] 设置中的“文件系统操作”更新为“工作区文件”,“编程能力”更新为“开发工具”。
- [x] 固定 `@vscode/ripgrep` 1.18.0;构建钩子按目标平台/架构从锁定平台包提取二进制,
安装包携带当前目标的 `rg` 和 MIT 许可证。
- [x] 本次聚焦测试 `212 passed, 1 skipped`;全量 `npm run lint` 和
`npm run build:bundle` 通过。构建钩子在 Windows x64 实际准备并运行随包
ripgrep 15.0.0。
- [x] 中文文档审校覆盖本目录 7 个 Markdown 文件,阻断项为 0。
- [ ] 全量 `npm test` 和 `npm run typecheck` 仍受同一工作树中并行开发的浏览器标签页接口及
测试夹具修改阻塞;当前类型错误位于 `ipc.test.ts` 和 `browser-tabs-electron-e2e.ts`,最终
全量验证需在这些改动稳定后复跑。
## 2026-09-06 命令工作目录限制修复
- FR-3 / US-A3:移除命令工作目录的工作区范围限制。绝对路径、`..` 和指向工作区外
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 已实施 |
| 版本 | 0.3 |
| 日期 | 2026-09-03 |
| 版本 | 0.4 |
| 日期 | 2026-09-09 |
| 关联 PRD | [PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
| 相关架构 | [助手工作栏与执行空间](../assistant-workbar/prd.md)、[本机工具环境](../local-tool-environment/technical-design.md) |
@@ -17,6 +17,7 @@
- `ModelAgentRuntime` 的多轮工具调用、上下文压缩、取消、用量和有界结果处理。
- `ModelToolProvider` 的直连模型内置工具、浏览器、Web、知识和自定义 MCP 聚合。
- `workspace_rg`、分页 `workspace_read_text` 和 `workspace_apply_patch` 工作区工具。
- `WorkspaceAccess` 的本机和远端工作区抽象。
- 跨平台子进程树终止辅助函数。
- 面向用户专家协作的 `SubagentScheduler` 和状态事件。专家使用具备已启用本机能力的
@@ -69,6 +70,29 @@ type BuiltinModelToolSummary = {
工具总数和 schema 总字节继续计入直连模型现有 100 个工具与 512 KiB 上限。
## 工作区工具契约
### 工具输入与执行
- `workspace_rg` 接受 `pattern`、相对目录、glob、固定字符串、忽略大小写、文件列表模式和
最大结果数。Main 固定追加 `--no-config`,内容搜索读取 `--json`,对模型返回紧凑文本。
- `workspace_read_text` 接受 `path`、从 1 开始的 `offset` 和 `limit`,通过现有
`WorkspaceAccess.readText` 字节分页组装带行号结果;`offsetBytes` 提供原始字节续读,
覆盖跨多个页面的超长单行。
- `workspace_apply_patch` 接受一个 `*** Begin Patch` 文本,支持 `Add File`、`Update File`
和 `Delete File`。全部操作先解析并验证;更新文件保留原换行和权限位,单文件原子替换。
- Ask 只注册前两个只读工具;Execute 注册全部三个工具。旧的目录列表和整文件覆盖不再进入
新工具清单。
- 输入和目标文件不设固定总量上限。单次工具结果仍遵守直连模型上下文边界;读取返回续读
位置,搜索截断时要求模型缩小路径、glob 或表达式。
### ripgrep 打包
固定依赖 `@vscode/ripgrep`,构建钩子根据 Electron 目标选择对应 Windows、macOS 或 Linux
x64/arm64 平台包,使用 lockfile integrity 校验后提取 `rg`。安装包只复制当前目标二进制和
MIT 许可证。运行时通过 `BundledRuntimePaths.ripgrep` 使用绝对路径,并将所在目录放到直连
模型进程 PATH 前部。
## 4. 进程工具契约
### 4.1 输入
@@ -303,7 +327,7 @@ DeepSeek Harness 的 Main 工具代理必须使用仅包含分配 MCP 和 Web
复用现有 `subagent` 公开事件和消息块,增加 `routingMode: 'native'` 表示模型主动委派。
`expertId` 不再作为编程 Subagent 必填身份;共享 schema 应改为执行者 discriminated union,
而不是伪造一个专家记录:
不得以专家记录代替该执行者:
```ts
type SubagentActor =
+11 -6
View File
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 已实施 |
| 版本 | 0.3 |
| 日期 | 2026-09-03 |
| 版本 | 0.4 |
| 日期 | 2026-09-09 |
| 设计系统 | [GoodBuddy 统一界面设计系统](../../../UI-DESIGN.md) |
| 关联 PRD | [PRD](./prd.md) |
| 关联逻辑 | [功能逻辑设计](./logic-design.md) |
@@ -15,7 +15,7 @@
本功能不增加一级页面、Composer 按钮或 Runtime 专属工具栏。用户主要通过三处理解能力:
1. **设置 > MCP > 直连模型**:查看内置编程能力说明。
1. **设置 > MCP > 直连模型**:查看工作区文件和开发工具说明。
2. **聊天消息活动块**:查看命令和 Subagent 的实时状态与结果。
3. **运行记录**:按当前 Conversation 或 Task 查看聚合执行活动。
@@ -23,10 +23,15 @@ Job、Subjob 和 Run 继续是内部对象,不创建树形 UI、独立路由
## 2. 设置中的能力目录
在现有直连模型工具目录增加“编程能力”分组:
直连模型工具目录使用两个本机开发分组:
```text
编程能力
工作区文件
搜索工作区
读取工作区文本
应用工作区补丁
开发工具
进程执行
使用当前用户权限运行 PowerShell、Bash 或 Sh 命令,默认在工作区执行,也可指定其他工作目录。
编程 Subagent
@@ -35,7 +40,7 @@ Job、Subjob 和 Run 继续是内部对象,不创建树形 UI、独立路由
要求:
- 两项默认属于直连模型,不提供持久 Switch。
- 这些工具默认属于直连模型,不提供持久 Switch。
- 设置目录只定义稳定工具能力,不增加 Shell 状态或重试控件;实际 Shell 标签在模型工具
定义和进程结果中返回。
- Runtime 是 OpenCode、Continue 或 DeepSeek Harness 时,不在其原生能力清单中伪造这两项;
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 已实施,跨平台 CI 待验证 |
| 版本 | 0.3 |
| 日期 | 2026-09-03 |
| 版本 | 0.4 |
| 日期 | 2026-09-09 |
| 关联 PRD | [直连模型 Agent 能力 PRD](./prd.md) |
| 关联逻辑 | [功能逻辑设计](./logic-design.md) |
@@ -54,8 +54,8 @@ When 目标不是目录或不存在,Then 命令不启动并返回准确错误
关联:FR-2、FR-9。
Given 测试命令退出码非零,When 进程结束,Then 工具正常返回退出码和 stderr,而不是终止
整个模型请求,And 模型可以读取失败原因、修改代码并再次运行。
Given 测试命令退出码非零,When 进程结束,Then 工具正常返回退出码和 stderr,模型请求
继续运行,And 模型可以读取失败原因、修改代码并再次运行。
### US-A5 取消长命令
@@ -81,6 +81,13 @@ Given 直连模型在 TLS 建连、网络传输或单次请求超时时失败且
## 3. Epic B:编程闭环
### US-B0 搜索和分页读取大型项目
关联:FR-4、FR-12。
Given 用户在 Ask 或 Execute 中处理本机项目,When 模型需要定位代码或读取大文件,Then
它可以使用随包 ripgrep 搜索内容或列出文件,并按行分页读取命中位置,不要求用户预装 `rg`。
### US-B1 修改后运行验证
关联:FR-2、FR-4。
@@ -102,6 +109,14 @@ Given 用户选择了 GoodBuddy 托管或自定义 Node/Python,When 新命令
Given 项目缺少所需命令或依赖,When Shell 返回错误,Then 工具保留退出码和错误文本,
And 模型说明缺失项或在 Execute 中采取用户要求的安装步骤,不把环境缺失报告为模型故障。
### US-B4 使用补丁修改文件
关联:FR-4、FR-12。
Given 用户选择 Execute,When 模型提交包含新增、修改或删除操作的工作区补丁,Then
GoodBuddy 在写入前验证全部路径和上下文,逐文件原子写入,并返回实际修改的文件列表。
Given 用户选择 Ask,Then 补丁工具不出现在清单中且伪造调用会被拒绝。
## 4. Epic C:Subagent 委派
### US-C1 委派聚焦编程任务
+177
View File
@@ -39,6 +39,7 @@
"@napi-rs/canvas": "1.0.3",
"@opencode-ai/plugin": "1.18.29",
"@opencode-ai/sdk": "1.18.29",
"@vscode/ripgrep": "1.18.0",
"@wecom/aibot-node-sdk": "^1.0.6",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-search": "0.16.0",
@@ -6029,6 +6030,182 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vscode/ripgrep": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.18.0.tgz",
"integrity": "sha512-ns5lWe44tSfbTMbVUsyB+I1819PVSw4AdpgK0RNkzfWfwy6+3IUNSxwSrfTno1/oWaS/hERNz+XLWVyga2aJBQ==",
"license": "MIT",
"optionalDependencies": {
"@vscode/ripgrep-darwin-arm64": "1.18.0",
"@vscode/ripgrep-darwin-x64": "1.18.0",
"@vscode/ripgrep-linux-arm": "1.18.0",
"@vscode/ripgrep-linux-arm64": "1.18.0",
"@vscode/ripgrep-linux-ia32": "1.18.0",
"@vscode/ripgrep-linux-ppc64": "1.18.0",
"@vscode/ripgrep-linux-riscv64": "1.18.0",
"@vscode/ripgrep-linux-s390x": "1.18.0",
"@vscode/ripgrep-linux-x64": "1.18.0",
"@vscode/ripgrep-win32-arm64": "1.18.0",
"@vscode/ripgrep-win32-ia32": "1.18.0",
"@vscode/ripgrep-win32-x64": "1.18.0"
}
},
"node_modules/@vscode/ripgrep-darwin-arm64": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@vscode/ripgrep-darwin-arm64/-/ripgrep-darwin-arm64-1.18.0.tgz",
"integrity": "sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@vscode/ripgrep-darwin-x64": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@vscode/ripgrep-darwin-x64/-/ripgrep-darwin-x64-1.18.0.tgz",
"integrity": "sha512-25b4gWbL138dGuQU244ebCKKc0q05ULBMoFSz9oAEUHNeqK/lOJViDS7DRvbDazzAzSEdan391Znks/R5mkaTQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@vscode/ripgrep-linux-arm": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-arm/-/ripgrep-linux-arm-1.18.0.tgz",
"integrity": "sha512-GDAvufNDHu8zqLEmXstalQF0Wh6wQvdsBi/Vg3Yi3CK4a8XoFXqqXVEHEZ9xQz3t0NfoSEc9JbvK9DDS6FxyxQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@vscode/ripgrep-linux-arm64": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-arm64/-/ripgrep-linux-arm64-1.18.0.tgz",
"integrity": "sha512-lQ/5zTG++U0E3IhVgS4EPTTn/U4okncaRMM5GOFfOYZywS4nuD31GhkHbNYlDk5CuDC68+hYJ0/eQeyCKJDA+g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@vscode/ripgrep-linux-ia32": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-ia32/-/ripgrep-linux-ia32-1.18.0.tgz",
"integrity": "sha512-YWLkSUtFd4Jh5EepIhA9RJSfv3uMAVMo+2rBIGHPBnvgLrZciIs2cDKei1/p6Wc/aCzUoHyMAg2R6tw4ZCBKGg==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@vscode/ripgrep-linux-ppc64": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-ppc64/-/ripgrep-linux-ppc64-1.18.0.tgz",
"integrity": "sha512-quXVY8fwQ8O/lvU1yrSqSl3jlUzysRSb+AfUfCL/tRtphxsKlFvPAejryZ6vg4Bgvn8XL74xb4qMCDmWgYrT5w==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@vscode/ripgrep-linux-riscv64": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-riscv64/-/ripgrep-linux-riscv64-1.18.0.tgz",
"integrity": "sha512-f5kBQBrWfQt8Q7OhSORuNDei5dkYagBj3y4jImSUXGMy8B/Ke7SltSRcUtjPv166FAFfHCAmWuZp3+cWnX2/Vw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@vscode/ripgrep-linux-s390x": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-s390x/-/ripgrep-linux-s390x-1.18.0.tgz",
"integrity": "sha512-rTOcJFGGcl2c07RUOWUo4U1ndnemKhY6A9hnMB18uk7jSgJc0d/QLBGWMWpumdtoJtpizn/wIv5mXIisJukusQ==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@vscode/ripgrep-linux-x64": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-x64/-/ripgrep-linux-x64-1.18.0.tgz",
"integrity": "sha512-mQ3bVrUpnD2vs7QT0vX90Lt0cnUq467uFtEktIdsJJmW296RoSULRGqWgzG1AKxyBpNDD6l4ZO4qKf6SgyC23Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@vscode/ripgrep-win32-arm64": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-arm64/-/ripgrep-win32-arm64-1.18.0.tgz",
"integrity": "sha512-vfTIjq1OHnzUjxZcHVQAMbnggp8dpGf+0QKFOZHwWPqFwXxQC8eCWM+5NUdoJ6yrElCeMzoUTXoK/LdZaniB+Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@vscode/ripgrep-win32-ia32": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-ia32/-/ripgrep-win32-ia32-1.18.0.tgz",
"integrity": "sha512-//rfAE+BOw5AC2EMmepmiE36jUuevtQYNQqqlw1s3m9FlRxjxEut97RkRPHAu9BG4mSojatZx+kXZXNdyI9caQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@vscode/ripgrep-win32-x64": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-x64/-/ripgrep-win32-x64-1.18.0.tgz",
"integrity": "sha512-KNPvtElldqILHdnAetujPaowkNbpqJy3ssIGGN6F6Kve9Qi+nNLI2DN01O83JjCEVQbCzl8Ov3QZ9Eov3BR8Dg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@wecom/aibot-node-sdk": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@wecom/aibot-node-sdk/-/aibot-node-sdk-1.0.6.tgz",
+8 -1
View File
@@ -124,7 +124,9 @@
"to": "runtimes/opencode",
"filter": [
"opencode",
"opencode.exe"
"opencode.exe",
"rg",
"rg.exe"
]
},
{
@@ -138,6 +140,10 @@
"from": "node_modules/opencode-ai/LICENSE",
"to": "licenses/opencode-ai-LICENSE"
},
{
"from": "node_modules/@vscode/ripgrep/LICENSE",
"to": "licenses/vscode-ripgrep-MIT.txt"
},
{
"from": "node_modules/@deepseek-ai/dsh-agent/LICENSE",
"to": "licenses/deepseek-harness-MIT.txt"
@@ -299,6 +305,7 @@
"@napi-rs/canvas": "1.0.3",
"@opencode-ai/plugin": "1.18.29",
"@opencode-ai/sdk": "1.18.29",
"@vscode/ripgrep": "1.18.0",
"@wecom/aibot-node-sdk": "^1.0.6",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-search": "0.16.0",
+18 -1
View File
@@ -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',
+18
View File
@@ -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',
+1
View File
@@ -380,6 +380,7 @@ export function createAgentRuntime(
workspaceAccess: getWorkspaceAccess(),
directModelSubagentScheduler:
capabilities.directModelSubagentScheduler,
ripgrepExecutablePath: capabilities.bundledRuntimePaths?.ripgrep,
launchEnvironmentProvider:
capabilities.launchEnvironmentProvider
})
+239
View File
@@ -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
)
}
+10 -2
View File
@@ -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
)
+152 -99
View File
@@ -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('不能超出工作区')
})
})
+338
View File
@@ -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=')
})
})
+109
View File
@@ -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}`
}
+6 -6
View File
@@ -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 })
+16 -15
View File
@@ -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
+9
View File
@@ -42,6 +42,15 @@ describe('Portable package resources', () => {
'agent-runtime-lock.json'
)
)
expect(requiredPaths).toContain(
join(
directory,
'resources',
'runtimes',
'opencode',
'rg.exe'
)
)
expect(requiredPaths).toContain(
join(
directory,
+27 -1
View File
@@ -243,6 +243,7 @@ function portableDirectory(parent: string): string {
join(directory, 'resources', 'runtimes', 'opencode'),
{ recursive: true }
)
mkdirSync(join(directory, 'resources', 'licenses'), { recursive: true })
mkdirSync(
join(directory, 'resources', 'runtimes', 'continue'),
{ recursive: true }
@@ -283,6 +284,8 @@ function portableDirectory(parent: string): string {
'{"artifacts":[]}'
],
['resources/runtimes/opencode/opencode.exe', 'MZ'],
['resources/runtimes/opencode/rg.exe', 'MZ'],
['resources/licenses/vscode-ripgrep-MIT.txt', 'MIT'],
['resources/runtimes/continue/package.json', '{}'],
[
'resources/runtimes/npm/bin/npm-cli.js',
@@ -891,6 +894,9 @@ describe('release build arguments', () => {
const packageName = 'opencode-windows-x64-baseline'
const integrity = 'sha512-test-integrity'
const executable = Buffer.from('desktop runtime')
const ripgrepPackageName = '@vscode/ripgrep-win32-x64'
const ripgrepIntegrity = 'sha512-ripgrep-test-integrity'
const ripgrepExecutable = Buffer.from('ripgrep runtime')
try {
const configPlugin = join(
projectRoot, '.runtime-resources', 'opencode-config',
@@ -909,7 +915,8 @@ describe('release build arguments', () => {
join(projectRoot, 'package.json'),
JSON.stringify({
dependencies: {
'@deepseek-ai/dsh-llm': '0.1.2-rc.1'
'@deepseek-ai/dsh-llm': '0.1.2-rc.1',
'@vscode/ripgrep': '1.18.0'
}
})
)
@@ -920,6 +927,10 @@ describe('release build arguments', () => {
[`node_modules/${packageName}`]: {
version: '1.18.29',
integrity
},
[`node_modules/${ripgrepPackageName}`]: {
version: '1.18.0',
integrity: ripgrepIntegrity
}
}
})
@@ -949,6 +960,21 @@ describe('release build arguments', () => {
.digest('hex')
})
)
writeFileSync(
join(projectRoot, '.runtime-resources', 'x64', 'rg.exe'),
ripgrepExecutable
)
writeFileSync(
join(projectRoot, '.runtime-resources', 'x64', '.ripgrep-ready.json'),
JSON.stringify({
packageName: ripgrepPackageName,
version: '1.18.0',
integrity: ripgrepIntegrity,
executableSha256: createHash('sha256')
.update(ripgrepExecutable)
.digest('hex')
})
)
await expect(
prepareBundledRuntimes({