a431d1ec95
Add OpenAI-compatible providers, TUI, slash commands, and model switching. Add local file tools plus optional yolo shell execution. Document config, usage, and development workflow.
332 lines
8.4 KiB
Go
332 lines
8.4 KiB
Go
package tools
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"agentu/internal/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'."}
|
|
},
|
|
"required": ["query"],
|
|
"additionalProperties": false
|
|
}`),
|
|
},
|
|
}
|
|
}
|
|
|
|
type fileSearchArgs struct {
|
|
Query string `json:"query"`
|
|
Path string `json:"path"`
|
|
Glob string `json:"glob"`
|
|
}
|
|
|
|
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 (t *FileSearchTool) ripgrep(ctx context.Context, args fileSearchArgs, path string) (string, error) {
|
|
rgArgs := []string{"--line-number", "--color", "never"}
|
|
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) {
|
|
var out bytes.Buffer
|
|
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) {
|
|
fmt.Fprintf(&out, "%s:%d:%s\n", path, i+1, line)
|
|
if out.Len() > MaxToolOutputBytes {
|
|
return errors.New("output limit reached")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil && err.Error() != "output limit reached" {
|
|
return "", err
|
|
}
|
|
if out.Len() == 0 {
|
|
return "no matches", nil
|
|
}
|
|
return FormatResult(out.String()), nil
|
|
}
|