1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io/fs"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12 "time"
13
14 "github.com/cloudwego/eino/components/tool"
15 "github.com/cloudwego/eino/schema"
16
17 "github.com/bmatcuk/doublestar/v4"
18)
19
20type globTool struct {
21 workingDir string
22}
23
24const (
25 GlobToolName = "glob"
26)
27
28type fileInfo struct {
29 path string
30 modTime time.Time
31}
32
33type GlobParams struct {
34 Pattern string `json:"pattern"`
35 Path string `json:"path"`
36}
37
38func (b *globTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
39 return &schema.ToolInfo{
40 Name: GlobToolName,
41 Desc: `- Fast file pattern matching tool that works with any codebase size
42- Supports glob patterns like "**/*.js" or "src/**/*.ts"
43- Returns matching file paths sorted by modification time
44- Use this tool when you need to find files by name patterns
45- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead`,
46 ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
47 "pattern": {
48 Type: "string",
49 Desc: "The glob pattern to match files against",
50 Required: true,
51 },
52 "path": {
53 Type: "string",
54 Desc: "The directory to search in. Defaults to the current working directory.",
55 },
56 }),
57 }, nil
58}
59
60func (b *globTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
61 var params GlobParams
62 if err := json.Unmarshal([]byte(args), ¶ms); err != nil {
63 return fmt.Sprintf("error parsing parameters: %s", err), nil
64 }
65
66 // If path is empty, use current working directory
67 searchPath := params.Path
68 if searchPath == "" {
69 searchPath = b.workingDir
70 }
71
72 files, truncated, err := globFiles(params.Pattern, searchPath, 100)
73 if err != nil {
74 return fmt.Sprintf("error performing glob search: %s", err), nil
75 }
76
77 // Format the output for the assistant
78 var output string
79 if len(files) == 0 {
80 output = "No files found"
81 } else {
82 output = strings.Join(files, "\n")
83 if truncated {
84 output += "\n(Results are truncated. Consider using a more specific path or pattern.)"
85 }
86 }
87
88 return output, nil
89}
90
91func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
92 // Make sure pattern starts with the search path if not absolute
93 if !strings.HasPrefix(pattern, "/") && !strings.HasPrefix(pattern, searchPath) {
94 // If searchPath doesn't end with a slash, add one before appending the pattern
95 if !strings.HasSuffix(searchPath, "/") {
96 searchPath += "/"
97 }
98 pattern = searchPath + pattern
99 }
100
101 // Open the filesystem for walking
102 fsys := os.DirFS("/")
103
104 // Convert the absolute pattern to a relative one for the DirFS
105 // DirFS uses the root directory ("/") so we should strip leading "/"
106 relPattern := strings.TrimPrefix(pattern, "/")
107
108 // Collect matching files
109 var matches []fileInfo
110
111 // Use doublestar to walk the filesystem and find matches
112 err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
113 // Skip directories from results
114 if d.IsDir() {
115 return nil
116 }
117 if skipHidden(path) {
118 return nil
119 }
120
121 // Get file info for modification time
122 info, err := d.Info()
123 if err != nil {
124 return nil // Skip files we can't access
125 }
126
127 // Add to matches
128 absPath := "/" + path // Restore absolute path
129 matches = append(matches, fileInfo{
130 path: absPath,
131 modTime: info.ModTime(),
132 })
133
134 // Check limit
135 if len(matches) >= limit*2 { // Collect more than needed for sorting
136 return fs.SkipAll
137 }
138
139 return nil
140 })
141 if err != nil {
142 return nil, false, fmt.Errorf("glob walk error: %w", err)
143 }
144
145 // Sort files by modification time (newest first)
146 sort.Slice(matches, func(i, j int) bool {
147 return matches[i].modTime.After(matches[j].modTime)
148 })
149
150 // Check if we need to truncate the results
151 truncated := len(matches) > limit
152 if truncated {
153 matches = matches[:limit]
154 }
155
156 // Extract just the paths
157 results := make([]string, len(matches))
158 for i, m := range matches {
159 results[i] = m.path
160 }
161
162 return results, truncated, nil
163}
164
165func skipHidden(path string) bool {
166 base := filepath.Base(path)
167 return base != "." && strings.HasPrefix(base, ".")
168}
169
170func NewGlobTool(workingDir string) tool.InvokableTool {
171 return &globTool{
172 workingDir,
173 }
174}