P0 会话可靠性收口(根治"模型思考着会话就停止"): - P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值 - P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷 - P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示 - P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层 - P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径 FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效 P1 修复面收口: - 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫 - 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码 - 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并 - 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口 P2 能力演进: - 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板 - 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口 - electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置 - @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入 - MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图 - 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表 P3 测试基建: - 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2) - 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3 - 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强 全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
/**
|
||
* 代码搜索工具(1 个)
|
||
*
|
||
* code_search — 基于 ripgrep 的高速代码搜索
|
||
*
|
||
* 相比 search_files 的纯 JS 实现,code_search 调用 ripgrep 子进程,
|
||
* 性能提升 10-100 倍,支持正则、文件类型过滤、上下文行展示。
|
||
* 适合大型代码库的精准搜索。
|
||
*
|
||
* @see standard/开发规范.md — 优先使用第三方成熟库
|
||
*/
|
||
|
||
import { execFile } from 'child_process';
|
||
import { promisify } from 'util';
|
||
import log from 'electron-log';
|
||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||
import type { MetonaToolDef } from '../../../harness/types';
|
||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||
// F1-2: 统一用 safeResolvePath(含 isPathWithinWorkspace + MEMORY.md 拦截)
|
||
// F4-2: 引入 extractErrorMessage 统一错误处理
|
||
import { safeResolvePath, extractErrorMessage } from './file-guard';
|
||
|
||
const execFileAsync = promisify(execFile);
|
||
|
||
export class CodeSearchTool implements IMetonaTool {
|
||
/** ripgrep 可用性缓存(实例级,便于测试重置) */
|
||
private rgAvailable: boolean | null = null;
|
||
|
||
/** 检测系统是否安装了 ripgrep */
|
||
private async checkRipgrep(): Promise<boolean> {
|
||
if (this.rgAvailable !== null) return this.rgAvailable;
|
||
try {
|
||
await execFileAsync('rg', ['--version'], { timeout: 3_000 });
|
||
this.rgAvailable = true;
|
||
} catch {
|
||
this.rgAvailable = false;
|
||
}
|
||
return this.rgAvailable;
|
||
}
|
||
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'code_search',
|
||
description:
|
||
'Search code using ripgrep. Supports regex patterns, file type filtering, and context lines. Much faster than search_files for large codebases. Falls back to JS implementation if ripgrep is not installed.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
pattern: { type: 'string', description: 'Regex pattern to search for' },
|
||
path: { type: 'string', description: 'Search directory (default: workspace root)' },
|
||
file_glob: { type: 'string', description: 'File name glob filter (e.g., "*.ts", "*.py")' },
|
||
case_sensitive: { type: 'boolean', description: 'Case sensitive search (default false)' },
|
||
context_before: {
|
||
type: 'number',
|
||
description: 'Lines of context before match (default 0, max 5)',
|
||
},
|
||
context_after: {
|
||
type: 'number',
|
||
description: 'Lines of context after match (default 0, max 5)',
|
||
},
|
||
max_results: { type: 'number', description: 'Maximum results (default 50, max 200)' },
|
||
},
|
||
required: ['pattern'],
|
||
},
|
||
category: MetonaToolCategory.SEARCH,
|
||
riskLevel: MetonaRiskLevel.SAFE,
|
||
requiresPermission: false,
|
||
timeoutMs: 30_000,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
// F4-2: 外层 try-catch 防止 safeResolvePath 抛出异常向上传播
|
||
// (read_file/write_file/list_directory 均有外层 try-catch,code_search 此前缺失)
|
||
try {
|
||
const pattern = args.pattern as string;
|
||
if (typeof pattern !== 'string' || !pattern) {
|
||
return { results: [], count: 0, error: 'Pattern is required and must be a string' };
|
||
}
|
||
if (pattern.length > 500) {
|
||
return { results: [], count: 0, error: 'Pattern too long (max 500 chars)' };
|
||
}
|
||
|
||
const searchPath = args.path
|
||
? safeResolvePath(args.path as string, context.workspacePath)
|
||
: context.workspacePath;
|
||
const fileGlob = args.file_glob as string | undefined;
|
||
const caseSensitive = (args.case_sensitive as boolean) ?? false;
|
||
const contextBefore = Math.min(5, Math.max(0, (args.context_before as number) ?? 0));
|
||
const contextAfter = Math.min(5, Math.max(0, (args.context_after as number) ?? 0));
|
||
const maxResults = Math.min(200, Math.max(1, (args.max_results as number) ?? 50));
|
||
|
||
const opts = { fileGlob, caseSensitive, contextBefore, contextAfter, maxResults };
|
||
|
||
// 优先使用 ripgrep,回退到 JS 实现
|
||
const hasRg = await this.checkRipgrep();
|
||
if (hasRg) {
|
||
return this.searchWithRipgrep(pattern, searchPath, opts, context);
|
||
}
|
||
return this.searchWithJs(pattern, searchPath, opts, context);
|
||
} catch (error) {
|
||
return { results: [], count: 0, error: extractErrorMessage(error), success: false };
|
||
}
|
||
}
|
||
|
||
/** 使用 ripgrep 子进程搜索 */
|
||
private async searchWithRipgrep(
|
||
pattern: string,
|
||
searchPath: string,
|
||
opts: {
|
||
fileGlob?: string;
|
||
caseSensitive: boolean;
|
||
contextBefore: number;
|
||
contextAfter: number;
|
||
maxResults: number;
|
||
},
|
||
context: ToolExecutionContext,
|
||
): Promise<unknown> {
|
||
const rgArgs: string[] = ['--json'];
|
||
|
||
if (!opts.caseSensitive) rgArgs.push('-i');
|
||
if (opts.contextBefore > 0) rgArgs.push('-B', String(opts.contextBefore));
|
||
if (opts.contextAfter > 0) rgArgs.push('-A', String(opts.contextAfter));
|
||
rgArgs.push('-g', '!MEMORY.md');
|
||
if (opts.fileGlob) rgArgs.push('-g', opts.fileGlob);
|
||
|
||
// #22 修复: 在 pattern 前加 -- 终止选项解析,防止以 - 开头的 pattern 被解释为选项
|
||
// 攻击场景:pattern="--help" 输出帮助而非搜索,pattern="--ignore-file /etc/passwd" 可读取任意文件,
|
||
// pattern="--files" 列出所有文件。execFile 已用数组参数防 shell 注入,但 ripgrep 自身选项解析仍需防护
|
||
rgArgs.push('--', pattern, searchPath);
|
||
|
||
try {
|
||
const { stdout } = await execFileAsync('rg', rgArgs, {
|
||
maxBuffer: 10 * 1024 * 1024,
|
||
timeout: 25_000,
|
||
// v0.8.0 P1-2: 引擎级 abort 信号 —— 用户中断会话时立即 kill rg 子进程
|
||
//(Node execFile 原生支持 signal 选项,触发时以 AbortError 拒绝)
|
||
signal: context.signal,
|
||
});
|
||
|
||
const results = this.parseRipgrepJsonOutput(stdout);
|
||
return {
|
||
results: results.slice(0, opts.maxResults),
|
||
count: results.length,
|
||
engine: 'ripgrep',
|
||
};
|
||
} catch (error) {
|
||
const err = error as {
|
||
code?: number;
|
||
signal?: string;
|
||
stdout?: string;
|
||
stderr?: string;
|
||
killed?: boolean;
|
||
message?: string;
|
||
name?: string;
|
||
};
|
||
// v0.8.0 P1-2: 引擎 abort → 直接以中断语义返回(不再回退 JS 搜索)
|
||
if (err.name === 'AbortError' || context.signal?.aborted) {
|
||
return { results: [], count: 0, error: 'Search aborted', engine: 'ripgrep', aborted: true };
|
||
}
|
||
// rg 退出码 1 = 无匹配,不是错误
|
||
if (err.code === 1) {
|
||
return { results: [], count: 0, engine: 'ripgrep' };
|
||
}
|
||
// 超时被 kill
|
||
if (err.killed || err.signal === 'SIGTERM') {
|
||
return { results: [], count: 0, error: 'ripgrep search timed out', engine: 'ripgrep' };
|
||
}
|
||
// 其他错误回退到 JS
|
||
log.warn('[CodeSearch] ripgrep failed, falling back to JS:', err.stderr || err.message);
|
||
return this.searchWithJs(pattern, searchPath, opts, context);
|
||
}
|
||
}
|
||
|
||
/** 解析 ripgrep --json 输出 */
|
||
/** @visibleForTesting 纯函数,供单元测试直接断言 ripgrep JSON 状态机 */
|
||
parseRipgrepJsonOutput(output: string): Array<{
|
||
path: string;
|
||
line: number;
|
||
column: number;
|
||
match: string;
|
||
before?: string[];
|
||
after?: string[];
|
||
}> {
|
||
const results: Array<{
|
||
path: string;
|
||
line: number;
|
||
column: number;
|
||
match: string;
|
||
before?: string[];
|
||
after?: string[];
|
||
}> = [];
|
||
const lines = output.split('\n').filter((l) => l.trim());
|
||
|
||
let currentMatch: {
|
||
path: string;
|
||
line: number;
|
||
column: number;
|
||
match: string;
|
||
before?: string[];
|
||
after?: string[];
|
||
} | null = null;
|
||
let beforeBuffer: string[] = [];
|
||
let afterBuffer: string[] = [];
|
||
|
||
for (const line of lines) {
|
||
let entry: Record<string, unknown>;
|
||
try {
|
||
entry = JSON.parse(line);
|
||
} catch {
|
||
continue;
|
||
}
|
||
|
||
const type = entry.type as string;
|
||
const data = entry.data as Record<string, unknown>;
|
||
|
||
if (type === 'context') {
|
||
const text = (data.lines as { text?: string } | undefined)?.text ?? '';
|
||
|
||
if (currentMatch) {
|
||
// 当前有 match,这是 after context
|
||
afterBuffer.push(text);
|
||
} else {
|
||
// 当前无 match,这是 before context
|
||
beforeBuffer.push(text);
|
||
}
|
||
} else if (type === 'match') {
|
||
// 新 match:先保存上一个 match 的 after context
|
||
if (currentMatch) {
|
||
if (afterBuffer.length > 0) currentMatch.after = [...afterBuffer];
|
||
results.push(currentMatch);
|
||
afterBuffer = [];
|
||
}
|
||
|
||
const text = (data.lines as { text?: string } | undefined)?.text ?? '';
|
||
const submatches =
|
||
(data.submatches as Array<{ match: { text?: string }; start?: number }> | undefined) ??
|
||
[];
|
||
const matchText = submatches[0]?.match?.text ?? text;
|
||
const column = (submatches[0]?.start ?? 0) + 1;
|
||
|
||
currentMatch = {
|
||
path: (data.path as { text?: string } | undefined)?.text ?? '',
|
||
line: data.line_number as number,
|
||
column,
|
||
match: matchText,
|
||
before: beforeBuffer.length > 0 ? [...beforeBuffer] : undefined,
|
||
};
|
||
beforeBuffer = [];
|
||
afterBuffer = [];
|
||
}
|
||
}
|
||
|
||
// 保存最后一个 match
|
||
if (currentMatch) {
|
||
if (afterBuffer.length > 0) currentMatch.after = [...afterBuffer];
|
||
results.push(currentMatch);
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
/** JS 回退实现 */
|
||
private async searchWithJs(
|
||
pattern: string,
|
||
searchPath: string,
|
||
opts: {
|
||
fileGlob?: string;
|
||
caseSensitive: boolean;
|
||
contextBefore: number;
|
||
contextAfter: number;
|
||
maxResults: number;
|
||
},
|
||
context: ToolExecutionContext,
|
||
): Promise<unknown> {
|
||
// 动态导入以避免循环依赖
|
||
const { SearchFilesTool } = await import('./filesystem');
|
||
const searchTool = new SearchFilesTool();
|
||
// v0.8.0 P1-3.5 根治: 回退路径此前静默丢失 case_sensitive / context_before /
|
||
// context_after —— 同一调用在 ripgrep 存在与否会产生不同结果(行为不对称)。
|
||
// SearchFilesTool 现已支持这三个参数(filesystem.ts P1-3.5),原样透传。
|
||
return searchTool.execute(
|
||
{
|
||
pattern,
|
||
target: 'content',
|
||
path: searchPath,
|
||
file_glob: opts.fileGlob,
|
||
limit: opts.maxResults,
|
||
case_sensitive: opts.caseSensitive,
|
||
context_before: opts.contextBefore,
|
||
context_after: opts.contextAfter,
|
||
},
|
||
context,
|
||
);
|
||
}
|
||
}
|