mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
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:
79
src/review/tools/code-search-tool.ts
Normal file
79
src/review/tools/code-search-tool.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
59
src/review/tools/file-read-tool.ts
Normal file
59
src/review/tools/file-read-tool.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
179
src/review/tools/function-reference-search-tool.ts
Normal file
179
src/review/tools/function-reference-search-tool.ts
Normal 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: '⚠️ 基于正则的近似搜索,可能包含字符串/注释中的匹配。建议查看实际代码确认。',
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
52
src/review/tools/registry.ts
Normal file
52
src/review/tools/registry.ts
Normal 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
27
src/review/tools/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user