package builtin // Glob 工具 -- 文件模式匹配搜索(双引擎版). // // 这是 Agent 发现文件的核心能力:通过 glob 模式(如 **/*.go, src/**/*.ts) // 在目录树中搜索匹配的文件路径. // // 升华改进(ELEVATED): 双引擎架构 - git 仓库中使用 git ls-files(利用 git 内部索引缓存, // 比 WalkDir 快 5-10 倍),非 git 仓库使用纯 Go filepath.WalkDir 兜底. // 替代方案:始终用 filepath.WalkDir(原始设计,逻辑统一但在大型仓库中性能不佳). // // 特性: // - 支持 ** 递归通配符和标准 glob 模式 // - Git 引擎:git ls-files + git ls-files --others(tracked + untracked) // - Walk 引擎:filepath.WalkDir + .gitignore 过滤 // - 支持 path 参数指定搜索目录 // - 支持 include_ignored 参数(强制包含被忽略的文件) // - 智能排序:小结果集(≤200)按 mtime 排序,大结果集按字母序 // - 超过 10000 个匹配文件时截断并提示总数 // - 结果中增加文件大小信息 // - ConcurrencySafe: true,ReadOnly: true import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" "git.flytoex.net/yuanwei/flyto-agent/pkg/permission" "git.flytoex.net/yuanwei/flyto-agent/pkg/tools" ) // GlobTool 是文件模式匹配搜索工具. type GlobTool struct { executor execenv.Executor } // NewGlobTool 创建一个 Glob 工具实例. executor 不能为 nil (方案 β 严格 DI). // M1: GlobTool 从无状态 struct 升级为持有 Executor, 用于 GitGlobEngine 子进程. func NewGlobTool(executor execenv.Executor) *GlobTool { if executor == nil { panic("builtin.NewGlobTool: executor is nil (方案 β 严格 DI)") } return &GlobTool{executor: executor} } // globInput 是 Glob 工具的输入参数. type globInput struct { Pattern string `json:"pattern"` Path string `json:"path,omitempty"` // 搜索目录,默认当前工作目录 IncludeIgnored bool `json:"include_ignored,omitempty"` // 强制包含被 .gitignore 忽略的文件 } // Name 返回工具名称. func (t *GlobTool) Name() string { return "Glob" } // Description 返回工具描述. func (t *GlobTool) Description(ctx context.Context) string { return "Fast file pattern matching tool that works with any codebase size. " + "Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\". " + "Respects .gitignore rules and excludes common non-essential directories by default. " + "Returns matching file paths with sizes, sorted by modification time (newest first) " + "for small result sets, or alphabetically for large result sets. " + "Use include_ignored to force include ignored files." } // InputSchema 返回工具的 JSON Schema 输入定义. func (t *GlobTool) InputSchema() json.RawMessage { return json.RawMessage(`{ "type": "object", "properties": { "pattern": { "type": "string", "description": "The glob pattern to match files against (e.g. **/*.go, src/**/*.ts)" }, "path": { "type": "string", "description": "The directory to search in. Defaults to the current working directory if not specified." }, "include_ignored": { "type": "boolean", "description": "Force include files that are normally ignored by .gitignore (default false)", "default": false } }, "required": ["pattern"] }`) } // Metadata 返回工具元数据. func (t *GlobTool) Metadata() tools.Metadata { return tools.Metadata{ ConcurrencySafe: true, ReadOnly: true, Destructive: false, SearchHint: "glob file pattern match find search", PermissionClass: permission.PermClassReadOnly, AuditOperation: "read", } } // fileWithInfo 用于排序和展示文件信息. type fileWithInfo struct { path string modTime int64 size int64 } // Execute 执行 glob 文件搜索. func (t *GlobTool) Execute(ctx context.Context, input json.RawMessage, progress tools.ProgressFunc) (*tools.Result, error) { var params globInput if err := json.Unmarshal(input, ¶ms); err != nil { return nil, fmt.Errorf("glob: invalid input: %w", err) } if params.Pattern == "" { return &tools.Result{ Output: "error: pattern is required", IsError: true, }, nil } // 确定搜索根目录 searchDir := params.Path if searchDir == "" { var err error searchDir, err = os.Getwd() if err != nil { return &tools.Result{ Output: fmt.Sprintf("error getting working directory: %v", err), IsError: true, }, nil } } // 确保搜索目录存在 info, err := os.Stat(searchDir) if err != nil { return &tools.Result{ Output: fmt.Sprintf("error: directory not found: %s", searchDir), IsError: true, }, nil } if !info.IsDir() { return &tools.Result{ Output: fmt.Sprintf("error: %s is not a directory", searchDir), IsError: true, }, nil } // 升华改进(ELEVATED): 根据环境自动选择最佳引擎. // include_ignored 时强制使用 Walk 引擎(git ls-files --others 不列出 ignored 文件). // 替代方案:始终使用 Walk 引擎(原始设计,逻辑简单但性能不佳). var engine GlobEngine if params.IncludeIgnored { engine = NewWalkGlobEngine() } else { engine = DetectGlobEngine(searchDir, t.executor) } matches, totalMatched, err := engine.Search(ctx, &GlobParams{ Pattern: params.Pattern, SearchDir: searchDir, IncludeIgnored: params.IncludeIgnored, }) if err != nil { return &tools.Result{ Output: fmt.Sprintf("error walking directory: %v", err), IsError: true, }, nil } if totalMatched == 0 { return &tools.Result{ Output: fmt.Sprintf("No files matched pattern: %s", params.Pattern), IsError: false, }, nil } // 智能排序 usedMtime := sortMatchesSmart(matches) // 构建输出(包含文件大小信息) var builder strings.Builder for _, m := range matches { builder.WriteString(m.path) builder.WriteString(" (") builder.WriteString(formatFileSize(m.size)) builder.WriteString(")\n") } // 如果超过上限,追加提示 const maxMatches = 10000 if totalMatched > maxMatches { fmt.Fprintf(&builder, "\n... truncated: showing %d of %d total matches. Narrow your pattern for more specific results.", maxMatches, totalMatched) } // 大结果集使用字母序时提示 if !usedMtime && totalMatched > mtimeSortThreshold { fmt.Fprintf(&builder, "\n(sorted alphabetically — %d results exceed mtime sort threshold of %d)", totalMatched, mtimeSortThreshold) } return &tools.Result{ Output: builder.String(), IsError: false, }, nil } // globMatch 实现支持 ** 的 glob 匹配. // ** 匹配任意数量的目录层级. func globMatch(pattern, name string) bool { // 统一使用 / 作为分隔符 pattern = filepath.ToSlash(pattern) name = filepath.ToSlash(name) return doGlobMatch(pattern, name) } // doGlobMatch 递归实现 glob 匹配. // 历史包袱(LEGACY): 递归 glob 匹配实现--Go 标准库 filepath.Match 不支持 **, // 必须手动实现.当前的递归算法在最坏情况下(如 `**/**/**/*.go`)性能是 O(n^k), // 其中 n 是路径段数,k 是 ** 的个数.对于正常的 glob 模式(1-2 个 **)足够快, // 但极端模式可能很慢.未来改进:考虑用 doublestar 第三方库替代. func doGlobMatch(pattern, name string) bool { for len(pattern) > 0 { if pattern == "**" { // ** 在末尾,匹配所有 return true } // 查找 ** 的位置 starStarIdx := strings.Index(pattern, "**") if starStarIdx == -1 { // 没有 **,使用标准 filepath.Match matched, _ := filepath.Match(pattern, name) return matched } if starStarIdx > 0 { // ** 前面有前缀部分 prefix := pattern[:starStarIdx] if strings.HasSuffix(prefix, "/") { prefix = prefix[:len(prefix)-1] } // name 必须以前缀开头(通过目录分段匹配) // 分成多段来匹配 parts := strings.SplitN(pattern, "**", 2) beforeStar := parts[0] afterStar := parts[1] // 去掉 ** 周围的 / if strings.HasSuffix(beforeStar, "/") { beforeStar = beforeStar[:len(beforeStar)-1] } if strings.HasPrefix(afterStar, "/") { afterStar = afterStar[1:] } // 检查 name 的前缀 if beforeStar != "" { nameParts := strings.SplitN(name, "/", len(strings.Split(beforeStar, "/"))+1) namePrefix := strings.Join(nameParts[:len(strings.Split(beforeStar, "/"))], "/") if len(nameParts) < len(strings.Split(beforeStar, "/")) { return false } matched, _ := filepath.Match(beforeStar, namePrefix) if !matched { return false } // 去掉匹配的前缀,继续匹配后缀 remaining := strings.Join(nameParts[len(strings.Split(beforeStar, "/")):], "/") if afterStar == "" { return true } return doGlobMatch("**/"+afterStar, remaining) } // beforeStar 为空,继续处理 pattern = "**" + afterStar if afterStar != "" { pattern = "**/" + afterStar } continue } // ** 在开头(starStarIdx == 0) rest := pattern[2:] if rest == "" { return true } if strings.HasPrefix(rest, "/") { rest = rest[1:] } if rest == "" { return true } // 尝试在 name 的每个位置匹配剩余模式 // ** 可以匹配 0 个或多个目录层级 nameParts := strings.Split(name, "/") for i := 0; i <= len(nameParts); i++ { remaining := strings.Join(nameParts[i:], "/") if doGlobMatch(rest, remaining) { return true } } return false } return name == "" }