package tools import ( "bytes" "context" "encoding/json" "errors" "fmt" "io/fs" "os" "os/exec" "path/filepath" "sort" "strings" "agentu/pkg/llm" ) type FileReadTool struct { workingDir string } func NewFileReadTool(workingDir string) *FileReadTool { return &FileReadTool{workingDir: workingDir} } func (t *FileReadTool) Definition() llm.Tool { return llm.Tool{ Type: "function", Function: llm.ToolFunction{ Name: "file_read", Description: "Read a local file as UTF-8 text.", Parameters: JSONSchema(`{ "type": "object", "properties": { "path": {"type": "string", "description": "File path to read. Relative paths are resolved from the configured agent working directory."}, "max_bytes": {"type": "integer", "description": "Optional maximum bytes to return."} }, "required": ["path"], "additionalProperties": false }`), }, } } type fileReadArgs struct { Path string `json:"path"` MaxBytes int `json:"max_bytes"` } func (t *FileReadTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { var args fileReadArgs if err := json.Unmarshal(raw, &args); err != nil { return "", fmt.Errorf("parse arguments: %w", err) } if strings.TrimSpace(args.Path) == "" { return "", errors.New("path is required") } path, err := resolvePath(t.workingDir, args.Path) if err != nil { return "", err } data, err := os.ReadFile(path) if err != nil { return "", err } limit := MaxToolOutputBytes if args.MaxBytes > 0 && args.MaxBytes < limit { limit = args.MaxBytes } if len(data) > limit { data = append(data[:limit], []byte(fmt.Sprintf("\n\n[truncated after %d bytes]", limit))...) } return string(data), ctx.Err() } type FileWriteTool struct { workingDir string } func NewFileWriteTool(workingDir string) *FileWriteTool { return &FileWriteTool{workingDir: workingDir} } func (t *FileWriteTool) Definition() llm.Tool { return llm.Tool{ Type: "function", Function: llm.ToolFunction{ Name: "file_write", Description: "Write UTF-8 text to a local file. Creates parent directories when needed.", Parameters: JSONSchema(`{ "type": "object", "properties": { "path": {"type": "string", "description": "File path to write. Relative paths are resolved from the configured agent working directory."}, "content": {"type": "string", "description": "Text content to write."}, "append": {"type": "boolean", "description": "Append instead of overwriting."} }, "required": ["path", "content"], "additionalProperties": false }`), }, } } type fileWriteArgs struct { Path string `json:"path"` Content string `json:"content"` Append bool `json:"append"` } func (t *FileWriteTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { var args fileWriteArgs if err := json.Unmarshal(raw, &args); err != nil { return "", fmt.Errorf("parse arguments: %w", err) } if strings.TrimSpace(args.Path) == "" { return "", errors.New("path is required") } path, err := resolvePath(t.workingDir, args.Path) if err != nil { return "", err } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return "", err } flag := os.O_CREATE | os.O_WRONLY | os.O_TRUNC if args.Append { flag = os.O_CREATE | os.O_WRONLY | os.O_APPEND } file, err := os.OpenFile(path, flag, 0o644) if err != nil { return "", err } defer file.Close() if _, err := file.WriteString(args.Content); err != nil { return "", err } if err := ctx.Err(); err != nil { return "", err } action := "wrote" if args.Append { action = "appended" } return fmt.Sprintf("%s %d bytes to %s", action, len(args.Content), path), nil } type FileListTool struct { workingDir string } func NewFileListTool(workingDir string) *FileListTool { return &FileListTool{workingDir: workingDir} } func (t *FileListTool) Definition() llm.Tool { return llm.Tool{ Type: "function", Function: llm.ToolFunction{ Name: "file_list", Description: "List files and directories at a local path.", Parameters: JSONSchema(`{ "type": "object", "properties": { "path": {"type": "string", "description": "Directory path to list. Defaults to the configured agent working directory."} }, "additionalProperties": false }`), }, } } type fileListArgs struct { Path string `json:"path"` } func (t *FileListTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { var args fileListArgs if len(raw) > 0 { if err := json.Unmarshal(raw, &args); err != nil { return "", fmt.Errorf("parse arguments: %w", err) } } path, err := resolvePath(t.workingDir, args.Path) if err != nil { return "", err } entries, err := os.ReadDir(path) if err != nil { return "", err } lines := make([]string, 0, len(entries)) for _, entry := range entries { name := entry.Name() if entry.IsDir() { name += "/" } lines = append(lines, name) } sort.Strings(lines) if err := ctx.Err(); err != nil { return "", err } return FormatResult(strings.Join(lines, "\n")), nil } type FileSearchTool struct { workingDir string } func NewFileSearchTool(workingDir string) *FileSearchTool { return &FileSearchTool{workingDir: workingDir} } func (t *FileSearchTool) Definition() llm.Tool { return llm.Tool{ Type: "function", Function: llm.ToolFunction{ Name: "file_search", Description: "Search local text files for a pattern. Uses ripgrep when available.", Parameters: JSONSchema(`{ "type": "object", "properties": { "query": {"type": "string", "description": "Text or regular expression to search for."}, "path": {"type": "string", "description": "Optional directory or file to search. Defaults to the configured agent working directory."}, "glob": {"type": "string", "description": "Optional file glob, for example '*.go'."}, "context_lines": {"type": "integer", "description": "Optional number of context lines around each match. Defaults to 0."}, "max_results": {"type": "integer", "description": "Optional maximum matches to return. Defaults to 100."} }, "required": ["query"], "additionalProperties": false }`), }, } } type fileSearchArgs struct { Query string `json:"query"` Path string `json:"path"` Glob string `json:"glob"` ContextLines int `json:"context_lines"` MaxResults int `json:"max_results"` } func (t *FileSearchTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { var args fileSearchArgs if err := json.Unmarshal(raw, &args); err != nil { return "", fmt.Errorf("parse arguments: %w", err) } if strings.TrimSpace(args.Query) == "" { return "", errors.New("query is required") } path, err := resolvePath(t.workingDir, args.Path) if err != nil { return "", err } if _, err := exec.LookPath("rg"); err == nil { return t.ripgrep(ctx, args, path) } return t.walkSearch(ctx, args, path) } func normalizedSearchOptions(args fileSearchArgs) (contextLines int, maxResults int) { contextLines = args.ContextLines if contextLines < 0 { contextLines = 0 } if contextLines > 5 { contextLines = 5 } maxResults = args.MaxResults if maxResults <= 0 { maxResults = 100 } if maxResults > 1000 { maxResults = 1000 } return contextLines, maxResults } func (t *FileSearchTool) ripgrep(ctx context.Context, args fileSearchArgs, path string) (string, error) { rgArgs := []string{"--line-number", "--color", "never"} contextLines, maxResults := normalizedSearchOptions(args) if contextLines > 0 { rgArgs = append(rgArgs, "--context", fmt.Sprint(contextLines)) } rgArgs = append(rgArgs, "--max-count", fmt.Sprint(maxResults)) if args.Glob != "" { rgArgs = append(rgArgs, "--glob", args.Glob) } rgArgs = append(rgArgs, args.Query, path) cmd := exec.CommandContext(ctx, "rg", rgArgs...) output, err := cmd.CombinedOutput() text := FormatResult(string(output)) if errors.Is(ctx.Err(), context.DeadlineExceeded) { return text, fmt.Errorf("search timed out: %w", ctx.Err()) } if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { return "no matches", nil } if text != "" { return text, fmt.Errorf("search failed: %w", err) } return "", fmt.Errorf("search failed: %w", err) } return text, nil } func (t *FileSearchTool) walkSearch(ctx context.Context, args fileSearchArgs, root string) (string, error) { contextLines, maxResults := normalizedSearchOptions(args) var out bytes.Buffer matchCount := 0 stop := errors.New("result limit reached") err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } if ctxErr := ctx.Err(); ctxErr != nil { return ctxErr } if d.IsDir() { name := d.Name() if name == ".git" || name == "vendor" || name == "node_modules" { return filepath.SkipDir } return nil } if args.Glob != "" { match, err := filepath.Match(args.Glob, filepath.Base(path)) if err != nil { return err } if !match { return nil } } data, err := os.ReadFile(path) if err != nil || bytes.IndexByte(data, 0) >= 0 { return nil } lines := strings.Split(string(data), "\n") for i, line := range lines { if !strings.Contains(line, args.Query) { continue } matchCount++ appendSearchMatch(&out, path, lines, i, contextLines) if matchCount >= maxResults { fmt.Fprintf(&out, "[stopped after %d matches; narrow path/query or raise max_results]\n", maxResults) return stop } if out.Len() > MaxToolOutputBytes { return stop } } return nil }) if err != nil && !errors.Is(err, stop) { return "", err } if out.Len() == 0 { return "no matches", nil } return FormatResult(out.String()), nil } func appendSearchMatch(out *bytes.Buffer, path string, lines []string, matchIndex int, contextLines int) { start := max(0, matchIndex-contextLines) end := min(len(lines)-1, matchIndex+contextLines) if contextLines > 0 { fmt.Fprintf(out, "-- %s:%d --\n", path, matchIndex+1) } for i := start; i <= end; i++ { marker := ":" if i == matchIndex { marker = "*" } fmt.Fprintf(out, "%s%s%d:%s\n", path, marker, i+1, lines[i]) } }