fix: preserve native ripgrep capabilities for direct models

Replace the restricted workspace_rg parameter mapping with native args and optional cwd. Preserve rg output and exit codes, and reuse process cancellation and output_read pagination so large search results remain accessible. Ask remains read-only within the workspace; Execute uses current-account permissions.

Validation: focused regression 169 passed, 1 skipped; typecheck and lint passed. Real DeepSeek Ask and Execute pagination scenarios passed with six counted requests in the successful run; an earlier failed fixture run has no retained request count. Full-suite validation is not green: the recorded run had unrelated failures and the latest run timed out without a summary. The remote Agent does not use this local execution path.

Release note: 直连模型可使用 ripgrep 原生参数搜索文件并保留原生结果,大输出可通过 output_read 完整续读。Ask 保持工作区只读,Execute 使用当前账号权限。
This commit is contained in:
mesalogo
2026-09-25 23:58:21 +08:00
parent d4bc10c0f3
commit ff746bdc1a
16 changed files with 418 additions and 513 deletions
+7 -7
View File
@@ -392,15 +392,15 @@ records are listed separately and do not introduce another feature status.
paths available to the current local or SSH account, including paths outside paths available to the current local or SSH account, including paths outside
the workspace and native subagent work. the workspace and native subagent work.
- [x] **Efficient direct-model workspace tools**: Direct models use bundled - [x] **Efficient direct-model workspace tools**: Direct models use bundled
ripgrep for compact file discovery and content search, read large UTF-8 files ripgrep with native arguments for file discovery and content search, read large
by line, and apply multi-file patches in Execute. Ask can search and read but UTF-8 files by line, and apply multi-file patches in Execute. Ask searches stay
cannot patch or run commands; packaged ripgrep does not require a system install. read-only within the workspace; Execute searches use current-account permissions.
Actionable read/search errors let the model correct its arguments and No system ripgrep install is required. Search preserves native output and exit
continue. Partial search results remain available with an explicit codes, with complete large results available through `output_read`; actionable
incomplete-coverage warning. argument errors let the model correct its request and continue.
- [x] **Long context and paged tool output**: Message history, long replies, - [x] **Long context and paged tool output**: Message history, long replies,
and parsed document text no longer use the previous fixed truncation limits. and parsed document text no longer use the previous fixed truncation limits.
Direct models can continue reading stored command and Subagent output in Direct models can continue reading stored search, command, and Subagent output in
pages; the selected model's context window and bounded transports still apply. pages; the selected model's context window and bounded transports still apply.
- [x] **Native Runtime interaction routing**: OpenCode and Continue questions - [x] **Native Runtime interaction routing**: OpenCode and Continue questions
support choices, yes/no, free-text answers, and skipping through the existing support choices, yes/no, free-text answers, and skipping through the existing
+2 -2
View File
@@ -47,8 +47,8 @@
- [x] **当前会话图片工具**:为图片模型开启会话调用后,支持工具调用的聊天模型可在 Execute 中生成图片、修改上传图片或历史成果,无需另开会话或手动切换图片模型。直连文本模型、本地 OpenCode、Continue、DeepSeek Harness 和托管远程 OpenCode 复用统一服务,Runtime 分配由模型设置派生;图片请求可能产生费用,编辑需服务商支持。详见[实现与验证记录](./docs/features/conversation-media-generation/progress.md)。 - [x] **当前会话图片工具**:为图片模型开启会话调用后,支持工具调用的聊天模型可在 Execute 中生成图片、修改上传图片或历史成果,无需另开会话或手动切换图片模型。直连文本模型、本地 OpenCode、Continue、DeepSeek Harness 和托管远程 OpenCode 复用统一服务,Runtime 分配由模型设置派生;图片请求可能产生费用,编辑需服务商支持。详见[实现与验证记录](./docs/features/conversation-media-generation/progress.md)。
- [x] **跨项目 Runtime 进程复用**:兼容配置的本地 OpenCode 和 DeepSeek Harness 共享重型进程,托管 OpenCode 会话在同一 Host 上复用进程。项目适配器及会话的工作区、模型、工具、问答和取消仍独立路由;远程待答问题和任务状态可在重连或桌面重启后恢复,不重发已接受请求。新的远程行为需要 Desktop `0.13.5` 与 Agent `0.13.0` 配套更新,详见[设计与实测](./docs/features/assistant-workbar/runtime-process-reuse-technical-design.md)。 - [x] **跨项目 Runtime 进程复用**:兼容配置的本地 OpenCode 和 DeepSeek Harness 共享重型进程,托管 OpenCode 会话在同一 Host 上复用进程。项目适配器及会话的工作区、模型、工具、问答和取消仍独立路由;远程待答问题和任务状态可在重连或桌面重启后恢复,不重发已接受请求。新的远程行为需要 Desktop `0.13.5` 与 Agent `0.13.0` 配套更新,详见[设计与实测](./docs/features/assistant-workbar/runtime-process-reuse-technical-design.md)。
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。 - [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
- [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 紧凑地发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并在 Execute 中应用多文件补丁。Ask 可以搜索和读取,但不能应用补丁或运行命令;用户无需另行安装 ripgrep。可处理的读取或搜索错误允许模型调整参数后继续,部分搜索结果保留并明确提示覆盖不完整。 - [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 的原生参数发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并在 Execute 中应用多文件补丁。Ask 限于工作区只读搜索和读取,Execute 按当前账号权限搜索;用户无需另行安装 ripgrep。搜索保留原生输出与退出码,大结果通过 `output_read` 完整续读,可处理的参数错误允许模型调整后继续。
- [x] **长上下文与工具输出分页**:历史消息、长回复和文档解析文本不再采用旧的固定截断限制。直连模型可分页续读已保存的命令和 Subagent 输出;所选模型的上下文窗口及传输边界仍然有效。 - [x] **长上下文与工具输出分页**:历史消息、长回复和文档解析文本不再采用旧的固定截断限制。直连模型可分页续读已保存的搜索、命令和 Subagent 输出;所选模型的上下文窗口及传输边界仍然有效。
- [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] **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 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 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 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖或新版本已经发布。当前源码候选为 Agent `0.13.3`、Desktop `0.13.12`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。 - [x] **托管 SSH OpenCode 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 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 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖或新版本已经发布。当前源码候选为 Agent `0.13.3`、Desktop `0.13.12`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
- [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)。 - [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)。
+3 -3
View File
@@ -22,9 +22,9 @@
| --- | --- | | --- | --- |
| 直连模型 | Runtime provider 为 `model`、由 GoodBuddy 直接调用模型协议的文本模型 | | 直连模型 | Runtime provider 为 `model`、由 GoodBuddy 直接调用模型协议的文本模型 |
| 进程执行 | GoodBuddy 在当前执行空间启动一个前台 Shell 命令并返回有界结果 | | 进程执行 | GoodBuddy 在当前执行空间启动一个前台 Shell 命令并返回有界结果 |
| 工作区搜索 | 通过安装包内置 ripgrep 搜索内容或列出文件,并返回紧凑结果 | | 工作区搜索 | 通过安装包内置 ripgrep 搜索内容或列出文件,接受原生参数并返回原生输出及退出码 |
| 工作区补丁 | 通过 `*** Begin Patch` 格式新增、修改或删除工作区文本文件 | | 工作区补丁 | 通过 `*** Begin Patch` 格式新增、修改或删除工作区文本文件 |
| 输出续读 | 通过 `output_read` 分页读取当前会话保留的进程或 Subagent 输出 | | 输出续读 | 通过 `output_read` 分页读取当前会话保留的搜索、进程或 Subagent 输出 |
| 平台 Shell | Windows 上的 PowerShell,macOS/Linux 上的 Bash 或 POSIX Sh | | 平台 Shell | Windows 上的 PowerShell,macOS/Linux 上的 Bash 或 POSIX Sh |
| 编程 Subagent | 由直连模型临时委派、使用直连模型 Runtime 执行一个有界子任务的执行者 | | 编程 Subagent | 由直连模型临时委派、使用直连模型 Runtime 执行一个有界子任务的执行者 |
| 父请求 | 发起进程调用或 Subagent 委派的当前直连模型请求 | | 父请求 | 发起进程调用或 Subagent 委派的当前直连模型请求 |
@@ -43,7 +43,7 @@ Task、Job、Subjob、Run 和 Subagent 的对象关系以
- OpenCode、Continue 和 DeepSeek Harness 继续使用各自原生执行与委派能力。 - OpenCode、Continue 和 DeepSeek Harness 继续使用各自原生执行与委派能力。
- Ask 可搜索和分页读取工作区,但不允许补丁写入或进程执行;Subagent 若在 Ask 中使用, - Ask 可搜索和分页读取工作区,但不允许补丁写入或进程执行;Subagent 若在 Ask 中使用,
只能继承 Ask 的只读能力。 只能继承 Ask 的只读能力。
- 进程和 Subagent 的长输出返回预览与续读位置,完整内容保留到所属会话或 Runtime 释放。 - 搜索、进程和 Subagent 的长输出返回预览与续读位置,完整内容保留到所属会话或 Runtime 释放。
Ask 和 Execute 均可续读当前会话已取得的输出,详见 [输出契约](./technical-design.md#43-输出边界)。 Ask 和 Execute 均可续读当前会话已取得的输出,详见 [输出契约](./technical-design.md#43-输出边界)。
- Execute 表示用户授权当前执行空间账号的完整能力,不增加第二套工具审批或权限档位。 - Execute 表示用户授权当前执行空间账号的完整能力,不增加第二套工具审批或权限档位。
- 首版跨平台指桌面本机 Windows、macOS 和 Linux。托管 SSH 项目当前继续使用远端 - 首版跨平台指桌面本机 Windows、macOS 和 Linux。托管 SSH 项目当前继续使用远端
@@ -26,8 +26,8 @@
3 次;用户取消和已显示的部分输出禁止重放。 3 次;用户取消和已显示的部分输出禁止重放。
12. Ask 可调用 `workspace_rg` 和 `workspace_read_text`,但不能调用 12. Ask 可调用 `workspace_rg` 和 `workspace_read_text`,但不能调用
`workspace_apply_patch`;Execute 可调用三者。 `workspace_apply_patch`;Execute 可调用三者。
13. 工作区搜索固定使用随包 ripgrep 的结构化输出,不把任意 ripgrep 参数转成命令执行。 13. 搜索直接使用随包 rg 的原生参数和输出;Ask 的参数检查保持工作区内只读,Execute 使用账号权限。
14. FR-7 / US-A6:预览之外的进程和 Subagent 输出保留在所属会话下,分页续读不启动新命令。 14. FR-7 / US-A6:预览之外的搜索、进程和 Subagent 输出保留在所属会话下,分页续读不启动新命令。
会话释放先停止活动工作,再删除输出;子会话结束不删除父会话拥有的 Subagent 结果。 会话释放先停止活动工作,再删除输出;子会话结束不删除父会话拥有的 Subagent 结果。
## 2. 状态维度 ## 2. 状态维度
@@ -58,7 +58,7 @@
FR-4 / FR-12 / US-B0:Ask 工作区搜索和读取不依赖知识库、联网搜索或委派开关,不需要 FR-4 / FR-12 / US-B0:Ask 工作区搜索和读取不依赖知识库、联网搜索或委派开关,不需要
切到 Execute 或额外批准。MCP 服务器失败也不改变本机只读工具的可用性。 切到 Execute 或额外批准。MCP 服务器失败也不改变本机只读工具的可用性。
`output_read` 在直连模型拥有进程或 Subagent 服务时提供,Ask/Execute 均可用,不取决于 `output_read` 在直连模型拥有搜索、进程或 Subagent 服务时提供,Ask/Execute 均可用,不取决于
本轮 Shell 可用性、是否允许再次委派或委派深度。读取只接受当前会话的句柄;其他 Runtime 本轮 Shell 可用性、是否允许再次委派或委派深度。读取只接受当前会话的句柄;其他 Runtime
不注册该工具,调用边界同样拒绝。 不注册该工具,调用边界同样拒绝。
+5 -4
View File
@@ -193,16 +193,17 @@ Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完
### FR-12 高效工作区工具 ### FR-12 高效工作区工具
- `workspace_rg` 同时支持内容搜索和文件列表,遵循项目忽略规则,并以 - `workspace_rg` 接受原生 rg 参数和可选工作目录,支持内容搜索、上下文、文件列表及
`path:line:column:text` 紧凑格式返回匹配。 自选输出格式,保留原生输出与退出码。默认遵循项目忽略规则。
- `workspace_read_text` 接受起始行和行数,大文件通过续读完成,不因超过旧的整文件上限而 - `workspace_read_text` 接受起始行和行数,大文件通过续读完成,不因超过旧的整文件上限而
完全不可读;超长单行可使用返回的字节位置继续读取。 完全不可读;超长单行可使用返回的字节位置继续读取。
- `workspace_apply_patch` 支持一个补丁新增、修改和删除多个 UTF-8 文件;执行前验证全部 - `workspace_apply_patch` 支持一个补丁新增、修改和删除多个 UTF-8 文件;执行前验证全部
路径和 hunk,执行失败时准确报告已完成文件。 路径和 hunk,执行失败时准确报告已完成文件。
- 搜索和读取在 Ask/Execute 均可用;补丁只在 Execute 可用,Main 调用边界再次检查模式。 - 搜索和读取在 Ask/Execute 均可用;补丁只在 Execute 可用,Main 调用边界再次检查模式。
- `workspace_rg` 使用固定参数调用随包 ripgrep,不向模型开放 `--pre` 或任意命令参数。 - `workspace_rg` 直接调用随包 ripgrep;Ask 限于工作区内只读搜索,Execute 使用当前账号权限。
具体参数与模式边界见[工具契约](./technical-design.md#工具输入与执行)。
- 工作区工具不限制文件总大小、补丁总大小、搜索表达式长度、glob 数量或模型请求的结果 - 工作区工具不限制文件总大小、补丁总大小、搜索表达式长度、glob 数量或模型请求的结果
数量。单次返回按上下文容量截断或分页;读取结果提供续读位置,搜索结果提示模型缩小查询。 数量。单次返回按上下文容量截断或分页;读取结果提供续读位置,搜索完整输出通过 `output_read` 续读。
## 6. 产品验收 ## 6. 产品验收
@@ -18,6 +18,31 @@ DeepSeek Harness 和托管 SSH 路径不注入这些工具。既有功能的 Win
全量项目验证已通过;本次分页验证记录见下方 2026-09-10 条目。macOS 与 Linux 真机验证 全量项目验证已通过;本次分页验证记录见下方 2026-09-10 条目。macOS 与 Linux 真机验证
仍需由对应平台完成。 仍需由对应平台完成。
## 2026-09-25 原生 ripgrep 参数与完整输出
- FR-12:`workspace_rg` 使用 `{ args: string[], cwd?: string }`,直接启动随包 rg,
保留原生 stdout、stderr 和退出码。此实现替代下方历史记录中的 JSON 解析与列号转换。
- 搜索复用进程服务的取消、进程树清理和会话输出存储;超过旧 4 MiB 上限的结果及超长行
可通过 `output_read` 完整续读。Ask 保留工作区只读边界,Execute 使用当前账号权限。
- Windows 真实随包 rg、生产 Provider 与 Runtime 接线聚焦回归:6 个文件,
`163 passed, 1 skipped`;覆盖原生参数、退出码 1/2、Ask 部分结果、长输出续读、
会话归属与清理。`npm run typecheck`、`npm run lint` 通过。
- 全量 `npm test`:`5091 passed, 72 skipped, 25 failed`,另有一个文件未发现测试套件,
合计 7 个测试文件失败。失败涉及监督视图与旧 schema 夹具、监督设置 UI 断言和
`scripts/review-algorithm.test.mjs`;本次未修改这些路径。全量开始后的最终搜索调整
已由上述 6 文件聚焦回归复验,不能把本次整库验证记为通过。
- 补充真实 DeepSeek 模型验收:Ask、Execute 两个场景均通过。模型通过原生参数调用
随包 rg,再通过 `output_read` 找回 110 KiB 超长单行末尾的随机标记;断言工具完成事件
进入 Runtime 活动。成功运行精确为 6 次上游请求(每个模式 3 次)、2 次搜索和 2 次续读。
首次测试因夹具未配置 rg 路径失败,控制台未保留请求计数,因此不推算整轮调用总数。
此验证覆盖真实 Runtime、Provider 和 rg,不作为完整桌面 UI 或 macOS/Linux 真机验证。
- 最终聚焦回归:6 个文件,`169 passed, 1 skipped`;Typecheck、Lint 通过。
最新全量运行在 600 秒执行窗口后超时,日志停留在附件布局 Electron 测试,未生成最终
汇总,因此整库验证仍不能记为通过。
- 已检查 `src/agent-daemon`、`src/main/remote-agent` 和远端 Runtime 打包入口:
它们不导入本机 rg 执行器、ModelToolProvider 或本机进程服务;远端工作区不提供该搜索
工具。因此本次不改变 gbagent 生产路径,不需要远端修复或 Host 验证。
## 2026-09-10 提交审查后的桌面 UI 验收 ## 2026-09-10 提交审查后的桌面 UI 验收
- Windows 隔离配置使用本轮最终开发构建,通过普通输入框发送触发完整 - Windows 隔离配置使用本轮最终开发构建,通过普通输入框发送触发完整
@@ -79,8 +79,10 @@ type BuiltinModelToolSummary = {
### 工具输入与执行 ### 工具输入与执行
- `workspace_rg` 接受 `pattern`、相对目录、glob、固定字符串、忽略大小写、文件列表模式和 - `workspace_rg` 接受 `{ args: string[], cwd?: string }`,直接启动随包 rg,不经过 Shell。
最大结果数。Main 固定追加 `--no-config`,内容搜索读取 `--json`,对模型返回紧凑文本。 默认参数为 `--no-config --color=never --line-number`,随后传入原生 args;cwd 默认工作区。
保留原生 stdout、stderr 和退出码,不强制 JSON、不裁剪匹配行。完整输出复用进程服务的
临时文件与 `output_read`;搜索不另设时限,取消和会话释放终止进程树。
- `workspace_read_text` 接受 `path`、从 1 开始的 `offset` 和 `limit`,通过现有 - `workspace_read_text` 接受 `path`、从 1 开始的 `offset` 和 `limit`,通过现有
`WorkspaceAccess.readText` 字节分页组装带行号结果;`offsetBytes` 提供原始字节续读, `WorkspaceAccess.readText` 字节分页组装带行号结果;`offsetBytes` 提供原始字节续读,
覆盖跨多个页面的超长单行。 覆盖跨多个页面的超长单行。
@@ -89,7 +91,7 @@ type BuiltinModelToolSummary = {
- Ask 只注册前两个只读工具;Execute 注册全部三个工具。旧的目录列表和整文件覆盖不再进入 - Ask 只注册前两个只读工具;Execute 注册全部三个工具。旧的目录列表和整文件覆盖不再进入
新工具清单。 新工具清单。
- 输入和目标文件不设固定总量上限。单次工具结果仍遵守直连模型上下文边界;读取返回续读 - 输入和目标文件不设固定总量上限。单次工具结果仍遵守直连模型上下文边界;读取返回续读
位置,搜索截断时要求模型缩小路径、glob 或表达式。 位置,搜索预览省略的内容通过 `output_read` 续读。
### 读取与搜索失败恢复 ### 读取与搜索失败恢复
@@ -102,19 +104,16 @@ Runtime 向模型返回 `{ ok: false, recoverable: true, error, nextAction }`
提示不得原样重复失败参数。取消、远端断线、失效工作区、缺失 rg 可执行文件及未分类的内部 提示不得原样重复失败参数。取消、远端断线、失效工作区、缺失 rg 可执行文件及未分类的内部
错误继续向外传播,不转为可恢复结果。 错误继续向外传播,不转为可恢复结果。
读取和搜索仍只接受工作区内相对路径,绝对路径即使指向工作区内文件也拒绝;原有越界及 `workspace_read_text` 仍只接受工作区内相对路径。搜索在 Ask 中检查 cwd、搜索路径、模式文件
符号链接检查继续生效。仅当本机直连模型 Execute 的 `process_execute` 实际可用时,恢复 和忽略文件位于工作区内,并阻止 `--pre`、`--hostname-bin`、`--follow/-L`、`--search-zip/-z`,
提示才建议用它访问工作区外文件。此处理不改变 Windows 用户权限、UAC 或文件 ACL, 避免只读调用启动外部命令或跟随链接越界。Execute 原样接受 rg 参数及账号可访问的路径,
也不提升进程权限;Ask 仍保持只读。 不增加逐工具授权。两种模式均禁用外部 rg 配置,不提升系统权限。
ripgrep 结果按退出状态处理: ripgrep 结果按退出状态处理:
- exit 1 保持正常无匹配结果。 - exit 1 保持正常无匹配结果。
- exit 2 有 stdout 且不是 regex/glob 编译错误时,保留已取得的匹配或文件列表,附加 - exit 2 返回原生 stderr 和已有 stdout,表示错误或搜索覆盖不完整;模型可修改参数继续。
`incomplete search coverage` 警告及有界 stderr。零匹配只代表已搜索部分,不能据此断言 无效正则同样返回 exit 2;字面搜索使用 `-F`。
整个目标不存在匹配;输出截断提示仍独立保留。
- exit 2 无 stdout,或出现无效 regex/glob 编译错误时,搜索层抛错,再由 Provider 转为
上述可恢复结果;模型可修正表达式,字面搜索可使用 `fixedStrings`。
该契约属于桌面 `ModelAgentRuntime → ModelToolProvider` 内置工具路径,复用浏览器标签页 该契约属于桌面 `ModelAgentRuntime → ModelToolProvider` 内置工具路径,复用浏览器标签页
关闭和元素引用失效已有的可恢复错误机制;浏览器分别提示 `browser_navigate` 或 关闭和元素引用失效已有的可恢复错误机制;浏览器分别提示 `browser_navigate` 或
@@ -203,7 +202,7 @@ type ProcessExecuteResult = {
页面在字符边界结束;若请求大小容不下首个完整字符,最多扩展到 4 字节,保证续读前进。 页面在字符边界结束;若请求大小容不下首个完整字符,最多扩展到 4 字节,保证续读前进。
位于 UTF-8 延续字节的输入 cursor、负数、越界和未知字段均拒绝。 位于 UTF-8 延续字节的输入 cursor、负数、越界和未知字段均拒绝。
Provider 根据 `process:` 或 `subagent:` 前缀选择服务,以当前 `conversationId` 校验所有者, Provider 根据 `rg:`、`process:` 或 `subagent:` 前缀选择服务,以当前 `conversationId` 校验所有者,
不接受模型另传 owner。Ask/Execute 均可用,工具总数为此预留一个槽位;不要求本轮能够启动 不接受模型另传 owner。Ask/Execute 均可用,工具总数为此预留一个槽位;不要求本轮能够启动
命令或再次委派。分页结果直接 JSON 序列化,不进行二次文本裁剪,以免 cursor 与内容不一致。 命令或再次委派。分页结果直接 JSON 序列化,不进行二次文本裁剪,以免 cursor 与内容不一致。
32 KiB 的页面即使全部需要 JSON 转义,也能保留在现有工具结果容量内。 32 KiB 的页面即使全部需要 JSON 转义,也能保留在现有工具结果容量内。
+40 -18
View File
@@ -52,10 +52,6 @@ export type ProcessExecuteInput = z.input<
typeof processExecuteInputSchema typeof processExecuteInputSchema
> >
type ParsedProcessExecuteInput = z.output<
typeof processExecuteInputSchema
>
export type ProcessShellSummary = { export type ProcessShellSummary = {
kind: 'powershell' | 'bash' | 'sh' kind: 'powershell' | 'bash' | 'sh'
label: string label: string
@@ -132,6 +128,7 @@ export type DirectModelProcessSpawn = (
) => DirectModelProcessChild ) => DirectModelProcessChild
export type LocalDirectModelProcessServiceOptions = { export type LocalDirectModelProcessServiceOptions = {
outputPrefix?: string
platform?: NodeJS.Platform platform?: NodeJS.Platform
environment?: NodeJS.ProcessEnv environment?: NodeJS.ProcessEnv
toolBinDirectory?: string toolBinDirectory?: string
@@ -296,7 +293,7 @@ function abortError(message: string): DOMException {
} }
async function resolveWorkingDirectory( async function resolveWorkingDirectory(
input: ParsedProcessExecuteInput, input: { cwd?: string },
workspace: WorkspaceAccess workspace: WorkspaceAccess
): Promise<{ canonicalPath: string; displayPath: string }> { ): Promise<{ canonicalPath: string; displayPath: string }> {
if (input.cwd?.includes('\0')) { if (input.cwd?.includes('\0')) {
@@ -343,7 +340,7 @@ export class LocalDirectModelProcessService
implements DirectModelProcessService implements DirectModelProcessService
{ {
private readonly activeCalls = new Map<string, Set<ActiveCall>>() private readonly activeCalls = new Map<string, Set<ActiveCall>>()
private readonly outputs = new PagedOutputStore('process') private readonly outputs: PagedOutputStore
private readonly shell: Promise<ResolvedProcessShell | undefined> private readonly shell: Promise<ResolvedProcessShell | undefined>
private disposed = false private disposed = false
private disposePromise?: Promise<void> private disposePromise?: Promise<void>
@@ -351,6 +348,7 @@ export class LocalDirectModelProcessService
constructor( constructor(
private readonly options: LocalDirectModelProcessServiceOptions = {} private readonly options: LocalDirectModelProcessServiceOptions = {}
) { ) {
this.outputs = new PagedOutputStore(options.outputPrefix ?? 'process')
this.shell = resolveProcessShell(options) this.shell = resolveProcessShell(options)
} }
@@ -375,6 +373,34 @@ export class LocalDirectModelProcessService
context: DirectModelProcessExecutionContext context: DirectModelProcessExecutionContext
): Promise<ProcessExecuteResult> { ): Promise<ProcessExecuteResult> {
const parsed = processExecuteInputSchema.parse(input) const parsed = processExecuteInputSchema.parse(input)
return this.trackExecution(async (signal) => {
const shell = await this.shell
signal.throwIfAborted()
if (!shell) throw new Error('当前平台没有可用的命令 Shell')
return this.executeTracked({
executable: shell.executable, args: shellArguments(shell, parsed.command),
cwd: parsed.cwd, timeoutMs: parsed.timeoutMs, shell
}, context, signal)
}, context)
}
async executeFile(
executable: string,
args: string[],
context: DirectModelProcessExecutionContext,
cwd?: string,
timeoutMs?: number,
shell: ProcessShellSummary = { kind: 'sh', label: executable }
): Promise<ProcessExecuteResult> {
return this.trackExecution((signal) => this.executeTracked(
{ executable, args, cwd, timeoutMs, shell }, context, signal
), context)
}
private async trackExecution(
run: (signal: AbortSignal) => Promise<ProcessExecuteResult>,
context: DirectModelProcessExecutionContext
): Promise<ProcessExecuteResult> {
if (this.disposed) { if (this.disposed) {
throw new Error('进程执行服务已关闭') throw new Error('进程执行服务已关闭')
} }
@@ -406,7 +432,7 @@ export class LocalDirectModelProcessService
} }
try { try {
return await this.executeTracked(parsed, context, controller.signal) return await run(controller.signal)
} finally { } finally {
context.signal.removeEventListener('abort', cancelFromCaller) context.signal.removeEventListener('abort', cancelFromCaller)
calls.delete(active) calls.delete(active)
@@ -418,16 +444,12 @@ export class LocalDirectModelProcessService
} }
private async executeTracked( private async executeTracked(
input: ParsedProcessExecuteInput, input: { executable: string; args: string[]; cwd?: string; timeoutMs?: number; shell: ProcessShellSummary },
context: DirectModelProcessExecutionContext, context: DirectModelProcessExecutionContext,
signal: AbortSignal signal: AbortSignal
): Promise<ProcessExecuteResult> { ): Promise<ProcessExecuteResult> {
signal.throwIfAborted() signal.throwIfAborted()
const shell = await this.shell const shell = input.shell
if (!shell) {
throw new Error('当前平台没有可用的命令 Shell')
}
signal.throwIfAborted()
const workingDirectory = await resolveWorkingDirectory( const workingDirectory = await resolveWorkingDirectory(
input, input,
context.workspace context.workspace
@@ -455,7 +477,7 @@ export class LocalDirectModelProcessService
child = ( child = (
this.options.spawnProcess ?? this.options.spawnProcess ??
(spawn as unknown as DirectModelProcessSpawn) (spawn as unknown as DirectModelProcessSpawn)
)(shell.executable, shellArguments(shell, input.command), { )(input.executable, input.args, {
cwd: workingDirectory.canonicalPath, cwd: workingDirectory.canonicalPath,
detached: platform !== 'win32', detached: platform !== 'win32',
env: environment, env: environment,
@@ -485,9 +507,9 @@ export class LocalDirectModelProcessService
private waitForProcess( private waitForProcess(
child: DirectModelProcessChild, child: DirectModelProcessChild,
shell: ResolvedProcessShell, shell: ProcessShellSummary,
cwd: string, cwd: string,
timeoutMs: number, timeoutMs: number | undefined,
cancellationSignal: AbortSignal, cancellationSignal: AbortSignal,
startedAt: number, startedAt: number,
stdout: PagedOutputWriter, stdout: PagedOutputWriter,
@@ -596,14 +618,14 @@ export class LocalDirectModelProcessService
failure = error failure = error
startCleanup() startCleanup()
} }
const timeout = setTimeout(() => { const timeout = timeoutMs === undefined ? undefined : setTimeout(() => {
if (settled || terminationReason === 'cancelled') { if (settled || terminationReason === 'cancelled') {
return return
} }
terminationReason = 'timeout' terminationReason = 'timeout'
startCleanup() startCleanup()
}, timeoutMs) }, timeoutMs)
timeout.unref?.() timeout?.unref?.()
child.stdout?.once('error', failFromOutput) child.stdout?.once('error', failFromOutput)
child.stderr?.once('error', failFromOutput) child.stderr?.once('error', failFromOutput)
+70 -120
View File
@@ -1,138 +1,88 @@
import { execFile, type ChildProcess } from 'node:child_process' import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { rgPath } from '@vscode/ripgrep' import { rgPath } from '@vscode/ripgrep'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { LocalWorkspaceAccess } from '../workspace' import { LocalWorkspaceAccess } from '../workspace'
import { searchWorkspaceWithRipgrep, type WorkspaceRipgrepInput } from './direct-model-ripgrep' import { LocalDirectModelProcessService } from './direct-model-process-service'
import { searchWorkspaceWithRipgrep } from './direct-model-ripgrep'
vi.mock('node:child_process', async (importOriginal) => { let root: string
const actual = await importOriginal<typeof import('node:child_process')>() let workspace: LocalWorkspaceAccess
const mocked = { ...actual, execFile: vi.fn(actual.execFile) } let service: LocalDirectModelProcessService
return { ...mocked, default: mocked } beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'goodbuddy-rg-'))
workspace = new LocalWorkspaceAccess(root)
service = new LocalDirectModelProcessService()
await writeFile(join(root, 'valid.txt'), 'before\ntarget\nafter\n')
}) })
const input: WorkspaceRipgrepInput = {
path: '.', pattern: 'target', glob: [], fixedStrings: false,
ignoreCase: false, filesOnly: false, maxResults: 100
}
const match = JSON.stringify({
type: 'match',
data: {
path: { text: './valid.txt' }, lines: { text: 'target\n' },
line_number: 1, submatches: [{ start: 0 }]
}
}) + '\n'
const accessError = 'blocked: Permission denied (os error 13)'
const directories: string[] = []
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process')
function mockResult(code: number | string, stdout: string, stderr: string) {
const error = Object.assign(new Error(`process failed: ${code}`), { code })
vi.mocked(execFile).mockImplementation((file, args, options, callback) => {
if (file !== rgPath) return actual.execFile(file, args, options, callback)
const complete = callback as (error: Error, stdout: string, stderr: string) => void
complete(error, stdout, stderr)
return {} as ChildProcess
})
return error
}
function search(overrides: Partial<WorkspaceRipgrepInput> = {}, signal = new AbortController().signal) {
return searchWorkspaceWithRipgrep(
rgPath, { ...input, ...overrides }, new LocalWorkspaceAccess(process.cwd()), signal
)
}
afterEach(async () => { afterEach(async () => {
vi.mocked(execFile).mockReset() await service.dispose()
await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))) await rm(root, { recursive: true, force: true })
}) })
const search = (args: string[], workMode: 'ask' | 'execute' = 'execute', signal = new AbortController().signal) =>
searchWorkspaceWithRipgrep(rgPath, { args }, workspace, signal, service, 'owner', workMode)
describe('searchWorkspaceWithRipgrep', () => { describe('native ripgrep', () => {
it('preserves formatted matches with bounded traversal diagnostics and an explicit warning', async () => { it('preserves native context, file listing, JSON and multiple expressions', async () => {
mockResult(2, match, accessError + '\n' + 'x'.repeat(10_000)) expect((await search(['-A1', '-B1', '-e', 'target', '-e', 'absent', '.'], 'ask')).stdout).toContain('after')
const result = await search() expect((await search(['--files', '-g', '*.txt'], 'ask')).stdout).toContain('valid.txt')
expect(vi.mocked(execFile).mock.calls.map((call) => call[0])).toContain(rgPath) expect((await search(['--json', 'target', '.'])).stdout).toContain('"type":"match"')
expect(result).toContain('valid.txt:1:1:target\\n')
expect(result).toContain('incomplete search coverage')
expect(result).toContain('missing results do not establish absence')
expect(result).toContain(accessError)
expect(result.length).toBeLessThan(2_500)
}) })
it('preserves no matches, regex errors and partial results with exit codes', async () => {
it('preserves file listings and existing result truncation', async () => { expect(await search(['absent', '.'])).toMatchObject({ exitCode: 1, stdout: '' })
mockResult(2, './valid.txt\0./other.txt\0', accessError) expect(await search(['[', '.'])).toMatchObject({ exitCode: 2, stderr: expect.stringContaining('regex parse error') })
const result = await search({ filesOnly: true, maxResults: 1 }) expect(await search(['target', 'valid.txt', 'missing.txt'], 'ask')).toMatchObject({
expect(result).toContain('incomplete search coverage') exitCode: 2, stdout: expect.stringContaining('target'), stderr: expect.stringContaining('missing.txt')
expect(result).toContain('\nvalid.txt\n...[truncated after 1 files]') })
expect(result).not.toContain('other.txt')
}) })
it('retains more than the former 4 MiB capture and resumes through EOF', async () => {
it('qualifies a no-match summary when traversal was incomplete', async () => { const text = 'target '.repeat(700_000) + 'END-MARKER\n'
mockResult(2, JSON.stringify({ type: 'summary', data: {} }) + '\n', accessError) await writeFile(join(root, 'large.txt'), text)
const result = await search() const result = await search(['--no-line-number', 'target', 'large.txt'])
expect(result).toContain('incomplete search coverage') expect(result.exitCode).toBe(0)
expect(result).toContain('[no matches found in searched files]') expect(result.stdoutTruncated).toBe(true)
expect(result).not.toContain('[no matches found]') const reference = result.stdoutReference!
let output = result.stdout
let cursor = reference.nextCursor
while (cursor < reference.totalBytes) {
const page = await service.readOutput('owner', reference.handle, cursor)
output += page.content
cursor = page.nextCursor
}
expect(output).toBe(text)
await expect(service.readOutput('another', reference.handle)).rejects.toThrow()
await service.releaseConversation('owner')
await expect(service.readOutput('owner', reference.handle)).rejects.toThrow()
}) })
it.each([['--pre=cmd', 'target'], ['--hostname-bin', 'cmd', 'target'], ['-L', 'target'], ['-z', 'target'], ['target', '../outside'], ['-f../outside']])('keeps Ask boundaries for %j', async (...args) => {
it.each([false, true])('throws detailed errors with no stdout (filesOnly=%s)', async (filesOnly) => { await expect(search(args, 'ask')).rejects.toThrow()
mockResult(2, '', accessError)
await expect(search({ filesOnly })).rejects.toThrow(accessError)
}) })
it('supports cwd and literal shell characters while ignoring external config', async () => {
it('does not turn invalid regex diagnostics into successful partial output', async () => { await mkdir(join(root, 'nested'))
mockResult(2, match, 'regex parse error:\n [\nerror: unclosed character class') await writeFile(join(root, 'nested', 'text.txt'), '$(echo unexpected); target\n')
await expect(search({ pattern: '[' })).rejects.toThrow('unclosed character class') await writeFile(join(root, 'config'), '--invalid-config-option\n')
const configured = new LocalDirectModelProcessService({ environment: { ...process.env, RIPGREP_CONFIG_PATH: join(root, 'config') } })
try {
const result = await searchWorkspaceWithRipgrep(rgPath, {
args: ['-F', '$(echo unexpected);', '.'], cwd: 'nested'
}, workspace, new AbortController().signal, configured, 'owner', 'ask')
expect(result).toMatchObject({ exitCode: 0, stdout: expect.stringContaining('$(echo unexpected); target') })
} finally { await configured.dispose() }
}) })
it('allows external Execute paths and rejects Ask directory links', async () => {
it('keeps launch failures as errors even with stdout', async () => { const outside = await mkdtemp(join(tmpdir(), 'goodbuddy-rg-outside-'))
const error = mockResult('ENOENT', match, '') try {
await expect(search()).rejects.toMatchObject({ cause: error }) await writeFile(join(outside, 'outside.txt'), 'target\n')
await symlink(outside, join(root, 'linked'), process.platform === 'win32' ? 'junction' : 'dir')
expect((await search(['target', outside])).exitCode).toBe(0)
await expect(search(['target', outside], 'ask')).rejects.toThrow('不能超出')
await expect(search(['target', 'linked'], 'ask')).rejects.toThrow()
} finally { await rm(outside, { recursive: true, force: true }) }
}) })
it('preserves cancellation', async () => {
it('preserves the cancellation reason even when partial stdout is available', async () => {
const controller = new AbortController() const controller = new AbortController()
const reason = new Error('cancel search') controller.abort(new Error('cancelled'))
controller.abort(reason) await expect(search(['target'], 'execute', controller.signal)).rejects.toThrow('cancelled')
mockResult(2, match, accessError)
await expect(search({}, controller.signal)).rejects.toBe(reason)
})
it('keeps exit 1 as a normal no-match result', async () => {
mockResult(1, '', '')
await expect(search()).resolves.toBe('[no matches found]')
})
it('preserves capture-limit truncation', async () => {
mockResult('ERR_CHILD_PROCESS_STDIO_MAXBUFFER', match, '')
await expect(search()).resolves.toBe('valid.txt:1:1:target\\n\n...[truncated after 1 matches]')
})
it.each([
{ filesOnly: false, pattern: 'target', expected: 'valid.txt:1:1:target\\n' },
{ filesOnly: true, pattern: 'target', expected: '\nvalid.txt' },
{ filesOnly: false, pattern: 'absent', expected: '[no matches found in searched files]' }
])('handles real bundled rg with a valid file plus a missing target ($filesOnly, $pattern)', async ({ filesOnly, pattern, expected }) => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-ripgrep-'))
directories.push(directory)
await writeFile(join(directory, 'valid.txt'), 'target\n')
// Add a second target at the process boundary: the public API stats its single target first.
vi.mocked(execFile).mockImplementation((file, args, options, callback) =>
actual.execFile(file, file === rgPath ? [...args as string[], 'missing.txt'] : args, options, callback)
)
const result = await searchWorkspaceWithRipgrep(
rgPath, { ...input, path: 'valid.txt', filesOnly, pattern },
new LocalWorkspaceAccess(directory), new AbortController().signal
)
expect(result).toContain(expected)
expect(result).toContain('incomplete search coverage (ripgrep exit code 2)')
expect(result).toContain('missing.txt')
})
it('rejects an invalid regex using the real bundled rg', async () => {
await expect(search({ pattern: '[' })).rejects.toThrow(/regex parse error/u)
}) })
}) })
+87 -240
View File
@@ -1,259 +1,106 @@
import { execFile } from 'node:child_process' import { isAbsolute, relative, resolve } from 'node:path'
import { z } from 'zod'
import type { WorkspaceAccess } from '../workspace' import type { WorkspaceAccess } from '../workspace'
import { LocalDirectModelProcessService } from './direct-model-process-service'
const MAXIMUM_CAPTURE_BYTES = 4 * 1024 * 1024 export const ripgrepInputSchema = z.object({
const MAXIMUM_RESULT_BYTES = 240 * 1024 args: z.array(z.string().refine((value) => !value.includes('\0'), 'Invalid argument')),
cwd: z.string().min(1).refine((value) => !value.includes('\0'), 'Invalid directory').optional()
}).strict()
export type WorkspaceRipgrepInput = z.infer<typeof ripgrepInputSchema>
export type WorkspaceRipgrepInput = { // Only Ask needs argument inspection: these options consume the next argument.
pattern?: string const valueOptions = new Set([
path: string 'after-context', 'before-context', 'context', 'context-separator', 'colors', 'color',
glob: string[] 'dfa-size-limit', 'encoding', 'engine', 'field-context-separator', 'field-match-separator',
fixedStrings: boolean 'glob', 'iglob', 'max-count', 'max-columns', 'max-depth', 'max-filesize',
ignoreCase: boolean 'path-separator', 'regex-size-limit', 'replace', 'sort', 'sortr', 'threads',
filesOnly: boolean 'type', 'type-not', 'type-add', 'type-clear', 'regexp', 'file', 'ignore-file',
maxResults: number 'hyperlink-format', 'pre-glob'
} ])
const shortValueOptions = new Set('ABCEefgMmrjtT'.split(''))
type RipgrepProcessResult = { async function checkAskArguments(input: WorkspaceRipgrepInput, workspace: WorkspaceAccess, signal: AbortSignal): Promise<void> {
stdout: string const identity = await workspace.getIdentity()
stderr: string const root = identity.canonicalDisplayPath
code: number | string | null | undefined const cwd = resolve(root, input.cwd ?? '.')
truncated: boolean const paths: string[] = [cwd]
} const positional: string[] = []
let hasPattern = false
function runRipgrep( let filesOnly = false
executablePath: string, let literal = false
args: string[], for (let i = 0; i < input.args.length; i++) {
cwd: string, const arg = input.args[i]!
signal: AbortSignal if (literal || !arg.startsWith('-') || arg === '-') {
): Promise<RipgrepProcessResult> { positional.push(arg)
return new Promise((resolve, reject) => { continue
execFile( }
executablePath, if (arg === '--') { literal = true; continue }
args, if (arg.startsWith('--')) {
{ const [name, ...inline] = arg.slice(2).split('=')
cwd, if (['pre', 'hostname-bin', 'follow', 'search-zip'].includes(name!)) {
encoding: 'utf8', throw new Error(`Ask mode does not allow --${name}; use Execute mode.`)
maxBuffer: MAXIMUM_CAPTURE_BYTES, }
signal, if (name === 'files') filesOnly = true
windowsHide: true if (name === 'regexp' || name === 'file') hasPattern = true
}, if (valueOptions.has(name!)) {
(error, stdout, stderr) => { const value = inline.length ? inline.join('=') : input.args[++i]
if (signal.aborted) { if (value !== undefined && (name === 'file' || name === 'ignore-file')) paths.push(resolve(cwd, value))
reject(signal.reason ?? error) }
return } else {
for (let j = 1; j < arg.length; j++) {
const flag = arg[j]!
if (flag === 'L' || flag === 'z') throw new Error(`Ask mode does not allow -${flag}; use Execute mode.`)
if (flag === 'e' || flag === 'f') hasPattern = true
if (shortValueOptions.has(flag)) {
const value = arg.slice(j + 1) || input.args[++i]
if (flag === 'f' && value !== undefined) paths.push(resolve(cwd, value))
break
} }
const processError = error as (Error & {
code?: number | string
killed?: boolean
}) | null
const output = stdout
const errors = stderr
if (
processError &&
processError.code !== 1 &&
processError.code !== 2 &&
processError.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
) {
reject(
new Error(
errors.trim().slice(0, 2_000) ||
processError.message ||
'ripgrep 执行失败',
{ cause: processError }
)
)
return
}
resolve({
stdout: output,
stderr: errors,
code: processError?.code ?? 0,
truncated:
processError?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
})
} }
)
})
}
function normalizeResultPath(value: string): string {
return value.replaceAll('\\', '/').replace(/^\.\//u, '')
}
function appendBounded(
lines: string[],
line: string,
state: { bytes: number }
): boolean {
const bytes = Buffer.byteLength(line) + 1
if (state.bytes + bytes > MAXIMUM_RESULT_BYTES) {
return false
}
lines.push(line)
state.bytes += bytes
return true
}
function formatFileResults(
stdout: string,
maximumResults: number,
initiallyTruncated: boolean
): string {
const paths = stdout
.split('\0')
.filter(Boolean)
.map(normalizeResultPath)
const lines: string[] = []
const state = { bytes: 0 }
let truncated = initiallyTruncated || paths.length > maximumResults
for (const path of paths.slice(0, maximumResults)) {
if (!appendBounded(lines, path, state)) {
truncated = true
break
} }
} }
if (truncated) { if (!hasPattern && !filesOnly) positional.shift()
lines.push(`...[truncated after ${lines.length} files]`) paths.push(...positional.filter((path) => path !== '-').map((path) => resolve(cwd, path)))
} for (const path of paths) {
return lines.join('\n') || '[no files found]' const local = relative(root, path)
} if (isAbsolute(local) || local.split(/[\\/]/u).includes('..')) throw new Error('文件路径不能超出项目工作区')
if (local) {
function textField(value: unknown): string | undefined { try {
if (!value || typeof value !== 'object' || !('text' in value)) { await workspace.stat({ path: local, signal })
return undefined } catch (error) {
} signal.throwIfAborted()
return typeof value.text === 'string' ? value.text : undefined // Let rg report missing search targets with its native exit code and stderr.
} if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error
}
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( export async function searchWorkspaceWithRipgrep(
executablePath: string, executablePath: string,
input: WorkspaceRipgrepInput, input: WorkspaceRipgrepInput,
workspace: WorkspaceAccess, workspace: WorkspaceAccess,
signal: AbortSignal signal: AbortSignal,
): Promise<string> { service: LocalDirectModelProcessService,
const identity = await workspace.getIdentity() conversationId: string,
if (identity.kind !== 'local') { workMode: 'ask' | 'execute'
throw new Error('内置 ripgrep 当前只支持本机工作区') ) {
} signal.throwIfAborted()
if (input.path !== '.') { if (workMode === 'ask') await checkAskArguments(input, workspace, signal)
await workspace.stat({ path: input.path, signal }) let execution
} try {
const args = ['--no-config', '--hidden', '--glob', '!.git/**'] execution = await service.executeFile(
for (const glob of input.glob) { executablePath, ['--no-config', '--color=never', '--line-number', ...input.args],
args.push('--glob', glob) { workspace, signal, conversationId }, input.cwd
}
if (input.filesOnly) {
args.push('--files', '--null', '--', input.path)
} else {
args.push('--json')
if (input.fixedStrings) args.push('--fixed-strings')
if (input.ignoreCase) args.push('--ignore-case')
args.push('--regexp', input.pattern!, '--', input.path)
}
const result = await runRipgrep(
executablePath,
args,
identity.canonicalDisplayPath,
signal
)
const incomplete = result.code === 2
if (
incomplete &&
(!result.stdout.trim() ||
/regex parse error|error parsing (?:regex|glob)|PCRE2: error compiling/iu.test(result.stderr))
) {
throw new Error(
`ripgrep failed (exit code 2); search coverage is incomplete.\n${result.stderr.trim().slice(0, 2_000) || 'No search output was available.'}`
) )
} catch (error) {
signal.throwIfAborted()
// Spawn failures are installation/environment failures, not search errors.
throw new Error('Unable to launch bundled ripgrep', { cause: error })
} }
const formatted = input.filesOnly const { shell, ...result } = execution
? formatFileResults( void shell
result.stdout, signal.throwIfAborted()
input.maxResults, return result
result.truncated
)
: formatMatchResults(
result.stdout,
input.maxResults,
result.truncated
)
if (incomplete) {
const warning = '[warning: incomplete search coverage (ripgrep exit code 2). Some files or directories could not be searched; missing results do not establish absence.]'
const details = result.stderr.trim().slice(0, 2_000)
const partial = formatted
.replace(/^\[no matches found\]$/u, '[no matches found in searched files]')
.replace(/^\[no files found\]$/u, '[no files found in searched locations]')
return `${warning}\n${details ? `${details}\n` : ''}${partial}`
}
return formatted
} }
+2 -2
View File
@@ -2234,7 +2234,7 @@ describe('ModelAgentRuntime', () => {
const reference = previous.stdoutReference! const reference = previous.stdoutReference!
expect(reference).toBeDefined() expect(reference).toBeDefined()
const calls = [ const calls = [
{ name: 'workspace_rg', arguments: { pattern: 'readonly-marker', glob: ['README.md'] } }, { name: 'workspace_rg', arguments: { args: ['-g', 'README.md', 'readonly-marker'] } },
{ name: 'workspace_read_text', arguments: { path: 'README.md' } }, { name: 'workspace_read_text', arguments: { path: 'README.md' } },
{ name: 'output_read', arguments: { handle: reference.handle, cursor: reference.nextCursor } } { name: 'output_read', arguments: { handle: reference.handle, cursor: reference.nextCursor } }
] ]
@@ -2261,7 +2261,7 @@ describe('ModelAgentRuntime', () => {
(message: { role: string }) => message.role === 'tool' (message: { role: string }) => message.role === 'tool'
) )
expect(results).toHaveLength(3) expect(results).toHaveLength(3)
expect(results[0].content).toContain('README.md:1:1:readonly-marker') expect(results[0].content).toContain('README.md:1:readonly-marker')
expect(results[1].content).toContain('readonly-marker') expect(results[1].content).toContain('readonly-marker')
expect(JSON.parse(results[2].content)).toMatchObject({ expect(JSON.parse(results[2].content)).toMatchObject({
content: expect.stringContaining('retained-tail'), eof: true content: expect.stringContaining('retained-tail'), eof: true
+17 -22
View File
@@ -416,7 +416,7 @@ describe('ModelToolProvider', () => {
}) })
const listing = await provider.callTool( const listing = await provider.callTool(
'workspace_rg', 'workspace_rg',
{ path: 'docs', filesOnly: true }, { args: ['--files', '--path-separator=/', 'docs'] },
signal, signal,
toolContext toolContext
) )
@@ -428,14 +428,14 @@ describe('ModelToolProvider', () => {
]) ])
const matches = await provider.callTool( const matches = await provider.callTool(
'workspace_rg', 'workspace_rg',
{ path: 'docs', pattern: 'target' }, { args: ['--path-separator=/', 'target', 'docs'] },
signal, signal,
toolContext toolContext
) )
expect(matches.parts).toEqual([ expect(matches.parts).toEqual([
expect.objectContaining({ expect.objectContaining({
type: 'text', type: 'text',
text: expect.stringContaining('docs/search.txt:1:2:中target') text: expect.stringContaining('docs/search.txt:1:中target')
}) })
]) ])
const written = await provider.callTool( const written = await provider.callTool(
@@ -493,16 +493,15 @@ describe('ModelToolProvider', () => {
const signal = new AbortController().signal const signal = new AbortController().signal
try { try {
for (const args of [ for (const args of [
{ pattern: 'x', path: workspace }, { pattern: 'x', path: 'missing' }, { args: 'x' }, { args: [1] }, { args: ['x'], extra: true }
{ pattern: '[' }, { pattern: 'x', glob: ['['] }, { maxResults: 0 }
]) { ]) {
await expect(provider.callTool('workspace_rg', args, signal, toolContext)).rejects.toMatchObject({ await expect(provider.callTool('workspace_rg', args, signal, toolContext)).rejects.toMatchObject({
name: 'RecoverableModelToolError', name: 'RecoverableModelToolError',
nextAction: expect.stringContaining('adjust the search path/globs') nextAction: expect.stringContaining('Correct native rg args')
}) })
} }
await expect(provider.callTool('workspace_rg', { pattern: '[', fixedStrings: true }, signal, toolContext)) await expect(provider.callTool('workspace_rg', { args: ['-F', '[', 'note.txt'] }, signal, toolContext))
.resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('note.txt:1:1:') }] }) .resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('1:[literal') }] })
} finally { } finally {
await provider.dispose() await provider.dispose()
} }
@@ -539,20 +538,14 @@ describe('ModelToolProvider', () => {
} }
}) })
it('recovers the wrapped rg exit code 2 but leaves a missing executable fatal', async () => { it('leaves a missing rg executable fatal', async () => {
const workspace = await createWorkspace() const workspace = await createWorkspace()
const access = new LocalWorkspaceAccess(workspace) const access = new LocalWorkspaceAccess(workspace)
const failure = new Error('regex parse error', {
cause: Object.assign(new Error('process failed'), { code: 2 })
})
vi.spyOn(access, 'stat').mockRejectedValueOnce(failure)
const provider = new ModelToolProvider(access, [], undefined, undefined, false, { const provider = new ModelToolProvider(access, [], undefined, undefined, false, {
ripgrepExecutablePath: join(workspace, 'missing-rg.exe') ripgrepExecutablePath: join(workspace, 'missing-rg.exe')
}) })
try { try {
await expect(provider.callTool('workspace_rg', { path: 'note.txt', pattern: '[' }, new AbortController().signal, toolContext)) const error = await provider.callTool('workspace_rg', { args: ['x'] }, new AbortController().signal, toolContext)
.rejects.toMatchObject({ name: 'RecoverableModelToolError', cause: failure })
const error = await provider.callTool('workspace_rg', { pattern: 'x' }, new AbortController().signal, toolContext)
.catch((error: unknown) => error) .catch((error: unknown) => error)
expect(error).toBeInstanceOf(Error) expect(error).toBeInstanceOf(Error)
expect(error).not.toBeInstanceOf(RecoverableModelToolError) expect(error).not.toBeInstanceOf(RecoverableModelToolError)
@@ -566,18 +559,19 @@ describe('ModelToolProvider', () => {
const access = new LocalWorkspaceAccess(await createWorkspace()) const access = new LocalWorkspaceAccess(await createWorkspace())
const provider = new ModelToolProvider(access, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath }) const provider = new ModelToolProvider(access, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath })
const operation = name === 'workspace_rg' ? vi.spyOn(access, 'stat') : vi.spyOn(access, 'readText') const operation = name === 'workspace_rg' ? vi.spyOn(access, 'stat') : vi.spyOn(access, 'readText')
const args = { path: 'note.txt', ...(name === 'workspace_rg' ? { pattern: 'x' } : {}) } const args = name === 'workspace_rg' ? { args: ['x', 'note.txt'] } : { path: 'note.txt' }
const context = { ...toolContext, workMode: 'ask' as const }
try { try {
const abortError = new DOMException('cancelled', 'AbortError') const abortError = new DOMException('cancelled', 'AbortError')
operation.mockRejectedValueOnce(abortError) operation.mockRejectedValueOnce(abortError)
await expect(provider.callTool(name, args, new AbortController().signal, toolContext)).rejects.toBe(abortError) await expect(provider.callTool(name, args, new AbortController().signal, context)).rejects.toBe(abortError)
const controller = new AbortController() const controller = new AbortController()
const reason = new Error('cancelled during access') const reason = new Error('cancelled during access')
operation.mockImplementationOnce(async () => { operation.mockImplementationOnce(async () => {
controller.abort(reason) controller.abort(reason)
throw Object.assign(new Error('missing'), { code: 'ENOENT' }) throw Object.assign(new Error('missing'), { code: 'ENOENT' })
}) })
await expect(provider.callTool(name, args, controller.signal, toolContext)).rejects.toBe(reason) await expect(provider.callTool(name, args, controller.signal, context)).rejects.toBe(reason)
await expect(provider.callTool(name, {}, controller.signal, toolContext)).rejects.toBe(reason) await expect(provider.callTool(name, {}, controller.signal, toolContext)).rejects.toBe(reason)
} finally { } finally {
await provider.dispose() await provider.dispose()
@@ -1040,6 +1034,7 @@ describe('ModelToolProvider', () => {
} as unknown as KnowledgeMcpGateway } as unknown as KnowledgeMcpGateway
const context = { const context = {
conversationId: 'knowledge-capacity', conversationId: 'knowledge-capacity',
runtimeTarget: 'model',
workMode: 'execute', workMode: 'execute',
knowledgeCapabilityToken: 'main-only-token' knowledgeCapabilityToken: 'main-only-token'
} satisfies ModelToolCallContext } satisfies ModelToolCallContext
@@ -1055,7 +1050,7 @@ describe('ModelToolProvider', () => {
})) }))
mocks.client.listTools.mockResolvedValueOnce({ mocks.client.listTools.mockResolvedValueOnce({
tools: createTools(82) tools: createTools(81)
}) })
const validProvider = new ModelToolProvider( const validProvider = new ModelToolProvider(
workspace, workspace,
@@ -1071,7 +1066,7 @@ describe('ModelToolProvider', () => {
await validProvider.dispose() await validProvider.dispose()
mocks.client.listTools.mockResolvedValueOnce({ mocks.client.listTools.mockResolvedValueOnce({
tools: createTools(83) tools: createTools(82)
}) })
const overflowingProvider = new ModelToolProvider( const overflowingProvider = new ModelToolProvider(
workspace, workspace,
@@ -1086,7 +1081,7 @@ describe('ModelToolProvider', () => {
context, context,
new AbortController().signal new AbortController().signal
) )
).resolves.toHaveLength(18) ).resolves.toHaveLength(19)
await overflowingProvider.dispose() await overflowingProvider.dispose()
}) })
+34 -77
View File
@@ -37,6 +37,7 @@ import {
normalizeMcpToolSchema normalizeMcpToolSchema
} from './mcp-tool-utils' } from './mcp-tool-utils'
import { import {
LocalDirectModelProcessService,
processExecuteInputSchema, processExecuteInputSchema,
type DirectModelProcessService type DirectModelProcessService
} from './direct-model-process-service' } from './direct-model-process-service'
@@ -48,7 +49,7 @@ import {
} from '../assistant/direct-model-subagent-service' } from '../assistant/direct-model-subagent-service'
import type { LaunchEnvironmentProvider } from '../local-tool-environment' import type { LaunchEnvironmentProvider } from '../local-tool-environment'
import type { BrowserTabId } from '../../shared/contracts' import type { BrowserTabId } from '../../shared/contracts'
import { searchWorkspaceWithRipgrep } from './direct-model-ripgrep' import { ripgrepInputSchema, searchWorkspaceWithRipgrep } from './direct-model-ripgrep'
import { applyWorkspacePatch } from './workspace-apply-patch' import { applyWorkspacePatch } from './workspace-apply-patch'
import { readWorkspaceLines } from './workspace-read-lines' import { readWorkspaceLines } from './workspace-read-lines'
import { import {
@@ -143,33 +144,6 @@ const readInputSchema = z
}) })
.strict() .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 const applyPatchInputSchema = z
.object({ .object({
patch: z.string().min(1) patch: z.string().min(1)
@@ -692,6 +666,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
: workspace : workspace
} }
private readonly ripgrepService = new LocalDirectModelProcessService({ outputPrefix: 'rg' })
private readonly workspaceAccess: WorkspaceAccess private readonly workspaceAccess: WorkspaceAccess
private getScopedTools( private getScopedTools(
@@ -766,7 +741,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
(this.webSearchEnabled ? 2 : 0) + (this.webSearchEnabled ? 2 : 0) +
(this.programming.processService ? 1 : 0) + (this.programming.processService ? 1 : 0) +
(this.programming.subagentService ? 1 : 0) + (this.programming.subagentService ? 1 : 0) +
(this.programming.processService || this.programming.subagentService (this.programming.ripgrepExecutablePath || this.programming.processService || this.programming.subagentService
? 1 : 0) + ? 1 : 0) +
(this.knowledgeGateway ? maximumScopedToolCount : 0) (this.knowledgeGateway ? maximumScopedToolCount : 0)
) )
@@ -835,34 +810,8 @@ export class ModelToolProvider implements ModelToolProviderLike {
name: workspaceRipgrepTool.name, name: workspaceRipgrepTool.name,
displayName: workspaceRipgrepTool.displayName, displayName: workspaceRipgrepTool.displayName,
description: 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.', 'Run bundled rg with native args (no shell), e.g. ["--files","-g","*.ts"] or ["-n","-i","TODO","src"]. cwd defaults to the workspace. Returns native stdout, stderr and exitCode: 1 means no matches; 2 means incomplete search/error. Use output_read with output references to continue large results. Ask permits read-only workspace searches; Execute uses current-user permissions. External rg config is disabled.',
inputSchema: { inputSchema: toModelToolJsonSchema(ripgrepInputSchema),
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' source: 'builtin'
}, },
{ {
@@ -1010,7 +959,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
): ModelToolDefinition[] { ): ModelToolDefinition[] {
if ( if (
context.runtimeTarget !== 'model' || context.runtimeTarget !== 'model' ||
(!this.programming.processService && !this.programming.subagentService) (!this.programming.ripgrepExecutablePath && !this.programming.processService && !this.programming.subagentService)
) { ) {
return [] return []
} }
@@ -1019,7 +968,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
name: outputReadTool.name, name: outputReadTool.name,
displayName: outputReadTool.displayName, displayName: outputReadTool.displayName,
description: description:
'Read retained process or Subagent output from this conversation. ' + 'Read retained search, process or Subagent output from this conversation. ' +
'Use the reference handle and nextCursor to continue a preview, or cursor 0 to reread. ' + 'Use the reference handle and nextCursor to continue a preview, or cursor 0 to reread. ' +
'Follow each page nextCursor until eof; cursors are UTF-8 byte offsets. ' + 'Follow each page nextCursor until eof; cursors are UTF-8 byte offsets. ' +
'Read-only in Ask and Execute. Handles expire when the conversation or runtime is released.', 'Read-only in Ask and Execute. Handles expire when the conversation or runtime is released.',
@@ -1397,7 +1346,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return { return {
scopeKey: 'model:builtin:output_read', scopeKey: 'model:builtin:output_read',
title: '允许续读工具输出?', title: '允许续读工具输出?',
description: '读取当前会话已保留的进程或 Subagent 输出,不运行新命令。', description: '读取当前会话已保留的搜索、进程或 Subagent 输出,不运行新命令。',
toolName: tool.displayName, toolName: tool.displayName,
argumentSummary, argumentSummary,
allowPermanent: false allowPermanent: false
@@ -1426,9 +1375,11 @@ export class ModelToolProvider implements ModelToolProviderLike {
description: description:
tool.source === 'mcp' tool.source === 'mcp'
? `该工具由已启用的 MCP Server「${tool.serverName ?? '未知'}」执行,并使用当前用户权限。` ? `该工具由已启用的 MCP Server「${tool.serverName ?? '未知'}」执行,并使用当前用户权限。`
: path : tool.name === 'workspace_rg' && context.workMode === 'execute'
? `目标位于当前工作区:${path}` ? '使用当前用户权限运行内置 rg。'
: '该工具仅允许访问当前工作区。', : path
? `目标位于当前工作区:${path}`
: '该工具仅允许访问当前工作区。',
toolName: tool.displayName, toolName: tool.displayName,
argumentSummary, argumentSummary,
allowPermanent: false allowPermanent: false
@@ -1746,13 +1697,18 @@ export class ModelToolProvider implements ModelToolProviderLike {
throw new Error('GoodBuddy 内置 ripgrep 不可用') throw new Error('GoodBuddy 内置 ripgrep 不可用')
} }
const input = ripgrepInputSchema.parse(argumentsValue) const input = ripgrepInputSchema.parse(argumentsValue)
return createTextToolResult( return createBoundedJsonObjectToolResult(
await searchWorkspaceWithRipgrep( await searchWorkspaceWithRipgrep(
this.programming.ripgrepExecutablePath, this.programming.ripgrepExecutablePath,
input, input,
this.workspaceAccess, this.workspaceAccess,
signal signal,
) this.ripgrepService,
context.conversationId,
context.workMode
),
['stdout', 'stderr'],
'ripgrep output could not be serialized'
) )
} }
const input = readInputSchema.parse(argumentsValue) const input = readInputSchema.parse(argumentsValue)
@@ -1780,21 +1736,18 @@ export class ModelToolProvider implements ModelToolProviderLike {
'工作区读取目标不是有效 UTF-8 文本', '工作区读取目标不是有效 UTF-8 文本',
'工作区读取范围无效' '工作区读取范围无效'
].includes(error.message) ].includes(error.message)
const rgExecutionError = name === 'workspace_rg' && ( const rgModeError = name === 'workspace_rg' && error.message.startsWith('Ask mode does not allow ')
(error.cause instanceof Error && 'code' in error.cause && error.cause.code === 2) ||
error.message.startsWith('ripgrep failed (exit code 2); search coverage is incomplete.\n')
)
if (!(error instanceof z.ZodError) && if (!(error instanceof z.ZodError) &&
!(typeof code === 'string' && expectedOsCodes.includes(code)) && !(typeof code === 'string' && expectedOsCodes.includes(code)) &&
!(typeof remoteCode === 'string' && [ !(typeof remoteCode === 'string' && [
...expectedOsCodes, 'invalid-path', 'invalid-utf8', ...expectedOsCodes, 'invalid-path', 'invalid-utf8',
'not-directory', 'special-file', 'symlink-rejected' 'not-directory', 'special-file', 'symlink-rejected'
].includes(remoteCode)) && ].includes(remoteCode)) &&
!expectedWorkspaceError && !rgExecutionError) { !expectedWorkspaceError && !rgModeError) {
throw error throw error
} }
let nextAction = name === 'workspace_rg' let nextAction = name === 'workspace_rg'
? 'Use workspace-relative paths; adjust the search path/globs or correct the pattern (use fixedStrings for literal text).' ? 'Correct native rg args or cwd; use -F for literal text and workspace paths in Ask mode.'
: 'Use a workspace-relative path to an existing readable UTF-8 file; correct the path or offset/limit, and discover the filename if needed.' : 'Use a workspace-relative path to an existing readable UTF-8 file; correct the path or offset/limit, and discover the filename if needed.'
nextAction += ' Do not retry identical arguments.' nextAction += ' Do not retry identical arguments.'
if ((await this.getProcessTool(context, signal)).length > 0) { if ((await this.getProcessTool(context, signal)).length > 0) {
@@ -1819,11 +1772,13 @@ export class ModelToolProvider implements ModelToolProviderLike {
throw new Error('当前请求不允许续读工具输出') throw new Error('当前请求不允许续读工具输出')
} }
const input = outputReadInputSchema.parse(argumentsValue) const input = outputReadInputSchema.parse(argumentsValue)
const service = input.handle.startsWith('process:') const service = input.handle.startsWith('rg:')
? this.programming.processService ? this.ripgrepService
: input.handle.startsWith('subagent:') : input.handle.startsWith('process:')
? this.programming.subagentService ? this.programming.processService
: undefined : input.handle.startsWith('subagent:')
? this.programming.subagentService
: undefined
if (!service) { if (!service) {
throw new Error('分页输出句柄无效或服务不可用') throw new Error('分页输出句柄无效或服务不可用')
} }
@@ -1963,6 +1918,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
this.webSearchBindings = undefined this.webSearchBindings = undefined
await Promise.allSettled([ await Promise.allSettled([
...clients.map((client) => client.close()), ...clients.map((client) => client.close()),
this.ripgrepService.dispose(),
this.programming.processService?.dispose(), this.programming.processService?.dispose(),
this.programming.subagentService?.dispose(), this.programming.subagentService?.dispose(),
this.workspaceAccess.dispose() this.workspaceAccess.dispose()
@@ -1971,6 +1927,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
async releaseConversation(conversationId: string): Promise<void> { async releaseConversation(conversationId: string): Promise<void> {
await Promise.allSettled([ await Promise.allSettled([
this.ripgrepService.releaseConversation(conversationId),
this.browserService?.releaseConversation(conversationId), this.browserService?.releaseConversation(conversationId),
this.programming.processService?.releaseConversation( this.programming.processService?.releaseConversation(
conversationId conversationId
+47
View File
@@ -0,0 +1,47 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { rgPath } from '@vscode/ripgrep'
import { expect, it } from 'vitest'
import { ModelToolProvider, type ModelToolResult } from './model-tool-provider'
it('pages native rg output through the production provider in Ask and releases it', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-rg-provider-'))
const provider = new ModelToolProvider(root, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath })
const context = { conversationId: 'rg-pages', runtimeTarget: 'model' as const, workMode: 'ask' as const }
const signal = new AbortController().signal
const parse = (result: ModelToolResult) => {
expect(result.contextBytes).toBeLessThanOrEqual(256 * 1024)
const part = result.parts[0]!
if (part.type !== 'text') throw new Error('Expected text')
return JSON.parse(part.text)
}
try {
const text = 'target 你好\\"'.repeat(30_000) + 'tail\n'
await writeFile(join(root, 'large.txt'), text)
expect(await provider.listTools(context, signal)).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'workspace_rg', inputSchema: expect.objectContaining({ required: ['args'] }) }),
expect.objectContaining({ name: 'output_read' })
]))
const result = parse(await provider.callTool('workspace_rg', { args: ['--no-line-number', 'target', 'large.txt'] }, signal, context))
let output = result.stdout
const reference = result.stdoutReference
let cursor = reference.nextCursor
while (cursor < reference.totalBytes) {
const page = parse(await provider.callTool('output_read', { handle: reference.handle, cursor }, signal, context))
output += page.content
expect(page.nextCursor).toBeGreaterThan(cursor)
cursor = page.nextCursor
}
expect(output).toBe(text)
const error = parse(await provider.callTool('workspace_rg', { args: ['[', '.'] }, signal, context))
expect(error).toMatchObject({ exitCode: 2, stderr: expect.stringContaining('regex parse error') })
await expect(provider.callTool('workspace_rg', { args: ['--pre=cmd', 'target'] }, signal, context))
.rejects.toMatchObject({ name: 'RecoverableModelToolError' })
await provider.releaseConversation(context.conversationId)
await expect(provider.callTool('output_read', { handle: reference.handle }, signal, context)).rejects.toThrow('不存在')
} finally {
await provider.dispose()
await rm(root, { recursive: true, force: true })
}
})
+62
View File
@@ -480,6 +480,68 @@ describe.runIf(enabled)('runtime end-to-end', () => {
180_000 180_000
) )
it.each(['ask', 'execute'] as const)(
'searches native rg arguments and reads hidden output through real model in %s',
async (workMode) => {
const marker = `RG_TAIL_${crypto.randomUUID()}`
await writeFile(join(workspace, 'native-rg.txt'), `target ${' '.repeat(110 * 1024)}${marker}\n`)
const upstreamUrl = protocol === 'anthropic-messages'
? createAnthropicMessagesUrl(baseUrl)
: protocol === 'openai-responses'
? createOpenAIResponsesUrl(baseUrl)
: createOpenAIChatCompletionsUrl(baseUrl)
const probe = await createModelRequestProbe({ upstreamUrl, headerName: 'x-goodbuddy-rg-test' })
const provider = new ModelToolProvider(workspace, [], undefined, undefined, false, {
ripgrepExecutablePath: join(process.cwd(), '.runtime-resources', process.arch, process.platform === 'win32' ? 'rg.exe' : 'rg')
})
const listTools = provider.listTools.bind(provider)
vi.spyOn(provider, 'listTools').mockImplementation(async (context, signal) =>
(await listTools(context, signal)).filter((tool) => ['workspace_rg', 'output_read'].includes(tool.name)))
const callTool = provider.callTool.bind(provider)
const observed: Array<{ name: string; result: ProcessExecuteResult | PagedOutputPage }> = []
vi.spyOn(provider, 'callTool').mockImplementation(async (name, args, signal, context) => {
if (observed.length >= 4) throw new Error('Real rg test exceeded four tool calls')
const result = await callTool(name, args, signal, context)
const part = result.parts[0]
if (part?.type === 'text') observed.push({ name, result: JSON.parse(part.text) })
return result
})
const runtime = new ModelAgentRuntime({
apiKey, baseUrl: probe.baseUrl, model: modelName, protocol,
authentication: 'api-key', defaultWorkspace: workspace,
maximumOutputTokens: 1024, toolProvider: provider
})
try {
const events = await collectEvents(runtime.run({
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), workMode,
prompt: [
'Call workspace_rg exactly once with args ["--no-line-number", "-F", "target", "native-rg.txt"].',
'The result has a hidden RG_TAIL_ marker after a long line of spaces.',
'Read the remaining output using output_read with stdoutReference.handle and stdoutReference.nextCursor.',
'Continue until eof. Reply only with the exact RG_TAIL_ marker. Do not guess or repeat the search.'
].join('\n')
}, AbortSignal.timeout(180000), async () => 'once'))
expect(observed.filter((item) => item.name === 'workspace_rg')).toHaveLength(1)
const search = observed[0]!.result as ProcessExecuteResult
expect(search.exitCode).toBe(0)
expect(search.stdoutTruncated).toBe(true)
expect(search.stdout).not.toContain(marker)
const pages = observed.filter((item) => item.name === 'output_read')
expect(pages.length).toBeGreaterThan(0)
expect(pages.at(-1)!.result).toMatchObject({ eof: true })
expect(events.filter((event) => event.type === 'text').map((event) => event.delta).join('')).toContain(marker)
expect(events.some((event) => event.type === 'tool' && event.state === 'completed')).toBe(true)
expect(events.at(-1)).toMatchObject({ type: 'done' })
} finally {
console.info(JSON.stringify({ boundary: 'native-rg-pagination', workMode,
realModelCalls: probe.observations.length, toolCalls: observed.map((item) => item.name) }))
await runtime.dispose()
await probe.close()
}
},
200000
)
it( it(
'recovers hidden process output markers through real-model output_read pagination', 'recovers hidden process output markers through real-model output_read pagination',
async () => { async () => {
+2 -2
View File
@@ -18,7 +18,7 @@ export const builtinModelTools = [
name: 'workspace_rg', name: 'workspace_rg',
displayName: '搜索工作区', displayName: '搜索工作区',
description: description:
'使用 GoodBuddy 内置 ripgrep 搜索工作区内容或列出文件,返回带路径和行号的紧凑结果。', '使用内置 ripgrep 原生参数搜索或列出文件;Ask 限于工作区只读搜索,Execute 使用当前账号权限,长输出可分页续读。',
access: 'read', access: 'read',
group: 'filesystem' group: 'filesystem'
}, },
@@ -113,7 +113,7 @@ export const builtinModelTools = [
{ {
name: 'output_read', name: 'output_read',
displayName: '续读工具输出', displayName: '续读工具输出',
description: '按 cursor 分页读取当前会话已保留的进程或 Subagent 输出;会话释放后失效。', description: '按 cursor 分页读取当前会话已保留的搜索、进程或 Subagent 输出;会话释放后失效。',
access: 'read', access: 'read',
group: 'programming' group: 'programming'
}, },