feat: 添加工具注册表和代码搜索工具

ToolRegistry统一管理Agent可用工具并转换为OpenAI Function格式;实现代码搜索、文件读取、函数引用搜索三个工具

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
accelerator
2026-03-01 03:26:47 +00:00
parent d1e1e2f33c
commit 6186210b4e
5 changed files with 396 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
import { z } from 'zod';
import { Tool } from './types';
import { SandboxExec } from '../context/sandbox-exec';
export function createCodeSearchTool(sandbox: SandboxExec): Tool {
return {
name: 'search_code',
description:
'在代码库中搜索匹配给定模式的代码,支持正则表达式。用于发现相似问题或影响范围。',
parameters: z.object({
pattern: z.string().describe('要搜索的正则表达式模式'),
file_types: z
.array(z.string())
.optional()
.describe('限制搜索的文件类型,如["ts", "js"]'),
max_results: z.number().default(20).describe('最大返回结果数'),
}),
execute: async (params, context) => {
const { pattern, file_types, max_results } = params;
// 构建ripgrep参数选项必须在--之前,--之后只能是pattern和路径等位置参数
const args = ['--json', '--max-count', String(max_results || 20)];
if (file_types && file_types.length > 0) {
args.push('--type-add', `custom:*.{${file_types.join(',')}}`);
args.push('--type', 'custom');
}
// 使用--分隔选项和pattern防止pattern以-开头时被误解析为ripgrep选项
args.push('--', pattern, context.workspacePath);
try {
const result = await sandbox.run('rg', args, {
cwd: context.workspacePath,
timeoutMs: 10000,
});
if (!result.stdout.trim()) {
return { matches: [], message: '未找到匹配结果' };
}
// 解析ripgrep JSON输出并过滤只保留match事件排除begin/end/summary
const matches = result.stdout
.split('\n')
.filter((line) => line.trim())
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter((event) => event && event.type === 'match')
.slice(0, max_results || 20);
return {
matches: matches.map((m: any) => ({
path: m.data?.path?.text || '',
line: m.data?.line_number || 0,
content: m.data?.lines?.text || '',
})),
total: matches.length,
};
} catch (error) {
// ripgrep返回exit code 1表示无匹配正常情况不应视为错误
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('code=1')) {
return { matches: [], message: '未找到匹配结果' };
}
// 其他错误(超时、权限等)才是真正的失败
return {
error: errorMessage,
matches: [],
};
}
},
};
}

View File

@@ -0,0 +1,59 @@
import { z } from 'zod';
import { Tool } from './types';
import { readFile, realpath } from 'node:fs/promises';
import path from 'node:path';
export function createFileReadTool(): Tool {
return {
name: 'read_file',
description: '读取指定文件的完整内容,用于深入分析代码逻辑。',
parameters: z.object({
file_path: z.string().describe('相对文件路径'),
start_line: z.number().optional().describe('起始行号(可选)'),
end_line: z.number().optional().describe('结束行号(可选)'),
}),
execute: async (params, context) => {
const { file_path, start_line, end_line } = params;
// 安全性规范化路径并验证是否在workspace内
const normalizedPath = path.normalize(file_path).replace(/^(\.\.[\/\\])+/, '');
const fullPath = path.resolve(context.workspacePath, normalizedPath);
try {
// 使用realpath解析完整路径跟随所有符号链接
const realPath = await realpath(fullPath);
const workspaceRealPath = await realpath(context.workspacePath);
// 验证解析后的真实路径必须在workspace目录下
if (!realPath.startsWith(workspaceRealPath + path.sep) && realPath !== workspaceRealPath) {
return {
error: `安全错误:路径 "${file_path}" 解析到workspace外部 (${realPath})`,
path: file_path,
};
}
const content = await readFile(realPath, 'utf-8');
if (start_line !== undefined && end_line !== undefined) {
const lines = content.split('\n');
return {
path: file_path,
content: lines.slice(start_line - 1, end_line).join('\n'),
lines: `${start_line}-${end_line}`,
};
}
return {
path: file_path,
content,
totalLines: content.split('\n').length,
};
} catch (error) {
return {
error: error instanceof Error ? error.message : String(error),
path: file_path,
};
}
},
};
}

View File

@@ -0,0 +1,179 @@
import { z } from 'zod';
import { Tool } from './types';
import { SandboxExec } from '../context/sandbox-exec';
// 转义正则元字符将identifier中的特殊字符转义为字面量
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function createFunctionReferenceSearchTool(sandbox: SandboxExec): Tool {
return {
name: 'search_function_references',
description: '搜索指定函数、方法或类的所有引用和定义(支持所有编程语言)。用于理解代码影响范围和调用关系。',
parameters: z.object({
identifier: z.string().describe('要搜索的标识符(函数名、类名、方法名等)'),
file_types: z
.array(z.string())
.optional()
.describe('限制搜索的文件类型,如["ts", "go", "py", "java"]'),
search_type: z
.enum(['calls', 'definitions', 'all'])
.default('all')
.describe('搜索类型calls=仅调用definitions=仅定义all=全部'),
max_results: z.number().default(30).describe('最大返回结果数'),
}),
execute: async (params, context) => {
const { identifier, file_types, search_type, max_results } = params;
// 转义identifier中的正则元字符避免被解释为正则语法
const escapedId = escapeRegex(identifier);
// 定义调用模式(适配多种语言)
const callPatterns: string[] = [
`${escapedId}\\s*\\(`, // 直接调用: functionName(
`\\.${escapedId}\\s*\\(`, // 方法调用: obj.methodName(
`::${escapedId}\\s*\\(`, // C++/Rust静态调用: Class::method(
`${escapedId}\\s*<[^>]+>\\s*\\(`, // 泛型调用: functionName<T>( (修复:限制<>内容)
];
// 定义声明模式(多语言)
const definitionPatterns: string[] = [
`func\\s+${escapedId}\\s*\\(`, // Go: func functionName(
`fn\\s+${escapedId}\\s*\\(`, // Rust: fn functionName(
`def\\s+${escapedId}\\s*\\(`, // Python: def functionName(
`function\\s+${escapedId}\\s*\\(`, // JavaScript: function functionName(
`${escapedId}\\s*:\\s*function`, // JS对象方法: methodName: function
`${escapedId}\\s*=\\s*\\([^)]*\\)\\s*=>`, // Arrow function: const fn = () => (修复:限制参数)
`class\\s+${escapedId}\\s*[{<]`, // 类定义: class ClassName {
`interface\\s+${escapedId}\\s*[{<]`, // 接口: interface InterfaceName {
`type\\s+${escapedId}\\s*=`, // 类型别名: type TypeName =
`struct\\s+${escapedId}\\s*[{]`, // Go/Rust struct: struct StructName {
`public\\s+[^(]*\\s+${escapedId}\\s*\\(`, // Java方法: public void methodName(
`private\\s+[^(]*\\s+${escapedId}\\s*\\(`, // Java私有方法
];
// 根据search_type选择模式
interface SearchTask {
patterns: string[];
type: 'call' | 'definition';
}
const tasks: SearchTask[] = [];
if (search_type === 'calls' || search_type === 'all') {
tasks.push({ patterns: callPatterns, type: 'call' });
}
if (search_type === 'definitions' || search_type === 'all') {
tasks.push({ patterns: definitionPatterns, type: 'definition' });
}
// 分别执行搜索任务
const allReferences: Array<{
path: string;
line: number;
content: string;
type: 'call' | 'definition';
}> = [];
for (const task of tasks) {
const pattern = task.patterns.join('|');
const args = [
'--json',
// 移除 --ignore-case保持大小写敏感大多数语言都是case-sensitive
'--max-count',
String(max_results || 30),
'-e',
pattern,
context.workspacePath,
];
if (file_types && file_types.length > 0) {
args.push('--type-add', `custom:*.{${file_types.join(',')}}`);
args.push('--type', 'custom');
}
try {
const result = await sandbox.run('rg', args, {
cwd: context.workspacePath,
timeoutMs: 15000,
});
if (result.stdout.trim()) {
// 解析ripgrep JSON输出
const matches = result.stdout
.split('\n')
.filter((line) => line.trim())
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter((event) => event && event.type === 'match');
// 转换为统一格式使用task.type作为分类
for (const m of matches) {
allReferences.push({
path: m.data?.path?.text || '',
line: m.data?.line_number || 0,
content: (m.data?.lines?.text || '').trim(),
type: task.type,
});
}
}
} catch (error) {
// ripgrep返回exit code 1表示无匹配这是正常的继续处理
const errorMessage = error instanceof Error ? error.message : String(error);
if (!errorMessage.includes('code=1')) {
// 非"无匹配"的错误才需要记录
console.warn(`Search ${task.type} failed:`, errorMessage);
}
}
}
// 去重(同一位置可能同时匹配调用和定义模式)
const uniqueRefs = new Map<string, typeof allReferences[0]>();
for (const ref of allReferences) {
const key = `${ref.path}:${ref.line}`;
if (!uniqueRefs.has(key)) {
uniqueRefs.set(key, ref);
} else {
// 如果重复优先保留definition类型
const existing = uniqueRefs.get(key)!;
if (ref.type === 'definition' && existing.type === 'call') {
uniqueRefs.set(key, ref);
}
}
}
const references = Array.from(uniqueRefs.values()).slice(0, max_results || 30);
if (references.length === 0) {
return {
identifier,
references: [],
total: 0,
message: `未找到 ${identifier} 的引用`,
note: '这是基于正则模式的近似搜索,可能遗漏动态调用或同名符号',
};
}
// 统计
const callCount = references.filter((r) => r.type === 'call').length;
const defCount = references.filter((r) => r.type === 'definition').length;
return {
identifier,
references,
total: references.length,
statistics: {
calls: callCount,
definitions: defCount,
},
summary: `找到 ${defCount} 个定义,${callCount} 个调用`,
note: '⚠️ 基于正则的近似搜索,可能包含字符串/注释中的匹配。建议查看实际代码确认。',
};
},
};
}

View File

@@ -0,0 +1,52 @@
import zodToJsonSchema from 'zod-to-json-schema';
import type { JsonSchema7Type } from 'zod-to-json-schema';
import { z } from 'zod';
import { Tool } from './types';
export class ToolRegistry {
private tools = new Map<string, Tool>();
register(tool: Tool): void {
this.tools.set(tool.name, tool);
}
get(name: string): Tool | undefined {
return this.tools.get(name);
}
getAll(): Tool[] {
return Array.from(this.tools.values());
}
// 转换为OpenAI Function定义
toOpenAIFunctions() {
return this.getAll().map((tool) => ({
type: 'function' as const,
function: {
name: tool.name,
description: tool.description,
parameters: this.zodToJsonSchema(tool.parameters),
},
}));
}
private zodToJsonSchema(schema: z.ZodTypeAny): JsonSchema7Type {
/**
* 使用zod-to-json-schema库转换Zod schema为JSON Schema。
*
* 注意该库v3.25.1使用了复杂的条件类型推断,会导致 TS2589
* "Type instantiation is excessively deep" 错误。这是库的已知限制,
* 见 https://github.com/StefanTerdell/zod-to-json-schema/issues
*
* 类型安全保证:
* - 输入z.ZodTypeAny 确保只接受Zod schema
* - 输出JsonSchema7Type 明确返回类型
* - 运行时行为:库本身经过充分测试,转换逻辑正确
*/
// @ts-expect-error TS2589: zod-to-json-schema v3.25.1 的条件类型过于复杂
return zodToJsonSchema(schema, {
target: 'openApi3',
$refStrategy: 'none',
});
}
}

27
src/review/tools/types.ts Normal file
View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
export interface Tool {
name: string;
description: string;
parameters: z.ZodTypeAny;
execute: (params: any, context: ToolExecutionContext) => Promise<any>;
}
export interface ToolExecutionContext {
workspacePath: string;
mirrorPath: string;
runId: string;
}
export interface ToolCall {
id: string;
toolName: string;
parameters: any;
}
export interface ToolResult {
toolCallId: string;
success: boolean;
result?: any;
error?: string;
}