glob.go

  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), &params); 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}