493 lines
14 KiB
Go
493 lines
14 KiB
Go
// This file implements the search tools (US-019): grep (search file contents by
|
||
// regexp with optional glob filtering), find (locate files by name glob), and ls
|
||
// (list a directory, distinguishing files from directories). All three resolve
|
||
// paths against a Root with the same boundary guard as the other tools and skip
|
||
// paths ignored by the workspace .gitignore. They are read-only → parallel.
|
||
package agenttool
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/smallnest/pigo/internal/agentcore"
|
||
)
|
||
|
||
// searchMaxResults caps the number of matches/entries any single search returns
|
||
// so a broad query cannot flood the model's context.
|
||
const searchMaxResults = 1000
|
||
|
||
// resolveWithin resolves p against root and verifies it stays within it. It is
|
||
// the single workspace-boundary policy shared by every file tool: the search
|
||
// tools call it directly, and ReadTool/WriteTool/EditTool.resolvePath delegate
|
||
// to it, so the path-traversal guard lives in exactly one place.
|
||
func resolveWithin(root, p string) (string, error) {
|
||
if root == "" {
|
||
wd, err := os.Getwd()
|
||
if err != nil {
|
||
return "", fmt.Errorf("cannot determine working directory: %w", err)
|
||
}
|
||
root = wd
|
||
}
|
||
absRoot, err := filepath.Abs(root)
|
||
if err != nil {
|
||
return "", fmt.Errorf("invalid root: %w", err)
|
||
}
|
||
var full string
|
||
if filepath.IsAbs(p) {
|
||
full = filepath.Clean(p)
|
||
} else {
|
||
full = filepath.Join(absRoot, p)
|
||
}
|
||
rel, err := filepath.Rel(absRoot, full)
|
||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||
return "", fmt.Errorf("path %q is outside the workspace root", p)
|
||
}
|
||
return full, nil
|
||
}
|
||
|
||
// resolveWithinAny resolves p against the first root that contains it, trying
|
||
// roots in order. It exists so the file tools can additionally permit trusted
|
||
// out-of-workspace roots (the skills directory) whose absolute SKILL.md paths
|
||
// pigo itself advertises in the system prompt: without this, the workspace guard
|
||
// would reject the very paths the model is instructed to read or author. Empty
|
||
// roots are skipped; if none contain p, the standard workspace-escape error is
|
||
// returned.
|
||
func resolveWithinAny(roots []string, p string) (string, error) {
|
||
var lastErr error
|
||
for _, root := range roots {
|
||
if root == "" {
|
||
continue
|
||
}
|
||
full, err := resolveWithin(root, p)
|
||
if err == nil {
|
||
return full, nil
|
||
}
|
||
lastErr = err
|
||
}
|
||
if lastErr != nil {
|
||
return "", lastErr
|
||
}
|
||
// No usable roots supplied: fall back to the default (cwd) policy.
|
||
return resolveWithin("", p)
|
||
}
|
||
|
||
// gitignore is a minimal .gitignore matcher. It supports the common subset:
|
||
// blank lines and #-comments are skipped; a leading "/" anchors to the root;
|
||
// a trailing "/" matches directories only; "!" negation re-includes; and plain
|
||
// patterns match by base name or path via filepath.Match. It is intentionally
|
||
// not a full gitignore implementation (no "**" spanning, no nested .gitignore).
|
||
type gitignore struct {
|
||
rules []ignoreRule
|
||
// hasSegmentRule is true when at least one rule matches by path segment
|
||
// (non-anchored, no "/"). Only then does ignored() need to split relPath into
|
||
// segments, so the common all-anchored case skips the split entirely.
|
||
hasSegmentRule bool
|
||
}
|
||
|
||
type ignoreRule struct {
|
||
pattern string
|
||
negate bool
|
||
dirOnly bool
|
||
anchored bool
|
||
// matchFull is precomputed at load time: an anchored pattern, or one that
|
||
// contains a "/", matches against the full relative path; otherwise the rule
|
||
// matches by base name or any single path segment. Hoisting this out of the
|
||
// per-file loop avoids a strings.Contains scan for every file × rule.
|
||
matchFull bool
|
||
}
|
||
|
||
// loadGitignore reads root/.gitignore. A missing file yields an empty matcher
|
||
// (matches nothing), never an error.
|
||
func loadGitignore(root string) *gitignore {
|
||
gi := &gitignore{}
|
||
data, err := os.ReadFile(filepath.Join(root, ".gitignore"))
|
||
if err != nil {
|
||
return gi
|
||
}
|
||
sc := bufio.NewScanner(strings.NewReader(string(data)))
|
||
for sc.Scan() {
|
||
line := strings.TrimRight(sc.Text(), " ")
|
||
if line == "" || strings.HasPrefix(line, "#") {
|
||
continue
|
||
}
|
||
r := ignoreRule{}
|
||
if strings.HasPrefix(line, "!") {
|
||
r.negate = true
|
||
line = line[1:]
|
||
}
|
||
if strings.HasSuffix(line, "/") {
|
||
r.dirOnly = true
|
||
line = strings.TrimSuffix(line, "/")
|
||
}
|
||
if strings.HasPrefix(line, "/") {
|
||
r.anchored = true
|
||
line = strings.TrimPrefix(line, "/")
|
||
}
|
||
if line == "" {
|
||
continue
|
||
}
|
||
r.pattern = line
|
||
r.matchFull = r.anchored || strings.Contains(line, "/")
|
||
if !r.matchFull {
|
||
gi.hasSegmentRule = true
|
||
}
|
||
gi.rules = append(gi.rules, r)
|
||
}
|
||
return gi
|
||
}
|
||
|
||
// ignored reports whether relPath (slash-separated, relative to root) is ignored.
|
||
// isDir refines dir-only rules. Later rules win, so a negation can re-include.
|
||
//
|
||
// The relPath is split into segments at most once per call (only when a
|
||
// segment-matching rule exists), rather than re-splitting inside the rule loop:
|
||
// this keeps the per-file cost O(rules) instead of O(rules × pathSegments),
|
||
// which matters because ignored() is called for every entry of a WalkDir.
|
||
func (g *gitignore) ignored(relPath string, isDir bool) bool {
|
||
relPath = filepath.ToSlash(relPath)
|
||
base := relPath
|
||
if i := strings.LastIndex(relPath, "/"); i >= 0 {
|
||
base = relPath[i+1:]
|
||
}
|
||
var segs []string
|
||
if g.hasSegmentRule {
|
||
segs = strings.Split(relPath, "/")
|
||
}
|
||
result := false
|
||
for _, r := range g.rules {
|
||
if r.dirOnly && !isDir {
|
||
continue
|
||
}
|
||
var match bool
|
||
if r.matchFull {
|
||
match, _ = filepath.Match(r.pattern, relPath)
|
||
} else {
|
||
match, _ = filepath.Match(r.pattern, base)
|
||
if !match {
|
||
// A non-anchored pattern also matches any path component,
|
||
// so an ignored directory hides everything beneath it.
|
||
for _, seg := range segs {
|
||
if ok, _ := filepath.Match(r.pattern, seg); ok {
|
||
match = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if match {
|
||
result = !r.negate
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// GrepTool searches file contents by regexp under Root, honoring .gitignore.
|
||
type GrepTool struct {
|
||
// Root bounds the search; empty defaults to the current working directory.
|
||
Root string
|
||
}
|
||
|
||
type grepToolArgs struct {
|
||
// Pattern is the regexp to search for (Go regexp syntax).
|
||
Pattern string `json:"pattern"`
|
||
// Path optionally scopes the search to a subdirectory (relative to Root).
|
||
Path string `json:"path,omitempty"`
|
||
// Glob optionally filters files by base-name glob (e.g. "*.go").
|
||
Glob string `json:"glob,omitempty"`
|
||
}
|
||
|
||
func (t *GrepTool) Name() string { return "grep" }
|
||
func (t *GrepTool) Description() string {
|
||
return "Search file contents by regular expression under the workspace, " +
|
||
"optionally filtering files by glob. Skips .gitignore'd paths."
|
||
}
|
||
func (t *GrepTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||
return agentcore.ToolExecutionParallel
|
||
}
|
||
func (t *GrepTool) Schema() json.RawMessage {
|
||
return json.RawMessage(`{
|
||
"type": "object",
|
||
"properties": {
|
||
"pattern": {"type": "string", "description": "Regular expression to search for."},
|
||
"path": {"type": "string", "description": "Subdirectory to scope the search to (relative to the workspace root)."},
|
||
"glob": {"type": "string", "description": "Filter files by base-name glob, e.g. *.go."}
|
||
},
|
||
"required": ["pattern"],
|
||
"additionalProperties": false
|
||
}`)
|
||
}
|
||
|
||
func (t *GrepTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||
a, bad := decodeArgs[grepToolArgs](args, "grep")
|
||
if bad != nil {
|
||
return *bad, nil
|
||
}
|
||
if a.Pattern == "" {
|
||
return errorResult("grep: pattern is required"), nil
|
||
}
|
||
re, err := regexp.Compile(a.Pattern)
|
||
if err != nil {
|
||
return errorResult(fmt.Sprintf("grep: invalid pattern: %v", err)), nil
|
||
}
|
||
root, err := resolveWithin(t.Root, "")
|
||
if err != nil {
|
||
return errorResult("grep: " + err.Error()), nil
|
||
}
|
||
start := root
|
||
if a.Path != "" {
|
||
if start, err = resolveWithin(t.Root, a.Path); err != nil {
|
||
return errorResult("grep: " + err.Error()), nil
|
||
}
|
||
}
|
||
gi := loadGitignore(root)
|
||
|
||
var matches []string
|
||
count := 0
|
||
walkErr := filepath.WalkDir(start, func(path string, d os.DirEntry, err error) error {
|
||
if err != nil {
|
||
return nil // skip unreadable entries
|
||
}
|
||
rel, _ := filepath.Rel(root, path)
|
||
if rel == "." {
|
||
return nil
|
||
}
|
||
if rel == ".git" || strings.HasPrefix(rel, ".git"+string(filepath.Separator)) {
|
||
if d.IsDir() {
|
||
return filepath.SkipDir
|
||
}
|
||
return nil
|
||
}
|
||
if gi.ignored(rel, d.IsDir()) {
|
||
if d.IsDir() {
|
||
return filepath.SkipDir
|
||
}
|
||
return nil
|
||
}
|
||
if d.IsDir() {
|
||
return nil
|
||
}
|
||
if a.Glob != "" {
|
||
if ok, _ := filepath.Match(a.Glob, d.Name()); !ok {
|
||
return nil
|
||
}
|
||
}
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
defer f.Close()
|
||
sc := bufio.NewScanner(f)
|
||
sc.Buffer(make([]byte, 0, scanBufInit), grepScanBufMax)
|
||
lineNo := 0
|
||
for sc.Scan() {
|
||
lineNo++
|
||
line := sc.Text()
|
||
if re.MatchString(line) {
|
||
matches = append(matches, fmt.Sprintf("%s:%d:%s", rel, lineNo, line))
|
||
count++
|
||
if count >= searchMaxResults {
|
||
return filepath.SkipAll
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if walkErr != nil {
|
||
return errorResult(fmt.Sprintf("grep: %v", walkErr)), nil
|
||
}
|
||
|
||
msg := fmt.Sprintf("%d match(es) for %q", len(matches), a.Pattern)
|
||
if len(matches) > 0 {
|
||
msg += "\n" + strings.Join(matches, "\n")
|
||
}
|
||
if count >= searchMaxResults {
|
||
msg += fmt.Sprintf("\n[truncated at %d matches]", searchMaxResults)
|
||
}
|
||
return agentcore.AgentToolResult{
|
||
Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
|
||
Details: map[string]any{"matches": len(matches)},
|
||
}, nil
|
||
}
|
||
|
||
// FindTool locates files by base-name glob under Root, honoring .gitignore.
|
||
type FindTool struct {
|
||
// Root bounds the search; empty defaults to the current working directory.
|
||
Root string
|
||
}
|
||
|
||
type findToolArgs struct {
|
||
// Glob is the base-name glob to match (e.g. "*.go").
|
||
Glob string `json:"glob"`
|
||
// Path optionally scopes the search to a subdirectory (relative to Root).
|
||
Path string `json:"path,omitempty"`
|
||
}
|
||
|
||
func (t *FindTool) Name() string { return "find" }
|
||
func (t *FindTool) Description() string {
|
||
return "Find files by base-name glob under the workspace. Skips .gitignore'd paths."
|
||
}
|
||
func (t *FindTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||
return agentcore.ToolExecutionParallel
|
||
}
|
||
func (t *FindTool) Schema() json.RawMessage {
|
||
return json.RawMessage(`{
|
||
"type": "object",
|
||
"properties": {
|
||
"glob": {"type": "string", "description": "Base-name glob to match, e.g. *.go."},
|
||
"path": {"type": "string", "description": "Subdirectory to scope the search to (relative to the workspace root)."}
|
||
},
|
||
"required": ["glob"],
|
||
"additionalProperties": false
|
||
}`)
|
||
}
|
||
|
||
func (t *FindTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||
a, bad := decodeArgs[findToolArgs](args, "find")
|
||
if bad != nil {
|
||
return *bad, nil
|
||
}
|
||
if a.Glob == "" {
|
||
return errorResult("find: glob is required"), nil
|
||
}
|
||
root, err := resolveWithin(t.Root, "")
|
||
if err != nil {
|
||
return errorResult("find: " + err.Error()), nil
|
||
}
|
||
start := root
|
||
if a.Path != "" {
|
||
if start, err = resolveWithin(t.Root, a.Path); err != nil {
|
||
return errorResult("find: " + err.Error()), nil
|
||
}
|
||
}
|
||
gi := loadGitignore(root)
|
||
|
||
var found []string
|
||
walkErr := filepath.WalkDir(start, func(path string, d os.DirEntry, err error) error {
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
rel, _ := filepath.Rel(root, path)
|
||
if rel == "." {
|
||
return nil
|
||
}
|
||
if rel == ".git" || strings.HasPrefix(rel, ".git"+string(filepath.Separator)) {
|
||
if d.IsDir() {
|
||
return filepath.SkipDir
|
||
}
|
||
return nil
|
||
}
|
||
if gi.ignored(rel, d.IsDir()) {
|
||
if d.IsDir() {
|
||
return filepath.SkipDir
|
||
}
|
||
return nil
|
||
}
|
||
if d.IsDir() {
|
||
return nil
|
||
}
|
||
if ok, _ := filepath.Match(a.Glob, d.Name()); ok {
|
||
found = append(found, rel)
|
||
if len(found) >= searchMaxResults {
|
||
return filepath.SkipAll
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if walkErr != nil {
|
||
return errorResult(fmt.Sprintf("find: %v", walkErr)), nil
|
||
}
|
||
sort.Strings(found)
|
||
|
||
msg := fmt.Sprintf("%d file(s) matching %q", len(found), a.Glob)
|
||
if len(found) > 0 {
|
||
msg += "\n" + strings.Join(found, "\n")
|
||
}
|
||
return agentcore.AgentToolResult{
|
||
Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
|
||
Details: map[string]any{"count": len(found)},
|
||
}, nil
|
||
}
|
||
|
||
// LsTool lists the entries of a directory, distinguishing files from directories.
|
||
type LsTool struct {
|
||
// Root bounds the listing; empty defaults to the current working directory.
|
||
Root string
|
||
}
|
||
|
||
type lsToolArgs struct {
|
||
// Path is the directory to list, relative to Root (empty = Root itself).
|
||
Path string `json:"path,omitempty"`
|
||
}
|
||
|
||
func (t *LsTool) Name() string { return "ls" }
|
||
func (t *LsTool) Description() string {
|
||
return "List a directory's entries, marking directories with a trailing slash."
|
||
}
|
||
func (t *LsTool) ExecutionMode() agentcore.ToolExecutionMode { return agentcore.ToolExecutionParallel }
|
||
func (t *LsTool) Schema() json.RawMessage {
|
||
return json.RawMessage(`{
|
||
"type": "object",
|
||
"properties": {
|
||
"path": {"type": "string", "description": "Directory to list, relative to the workspace root."}
|
||
},
|
||
"additionalProperties": false
|
||
}`)
|
||
}
|
||
|
||
func (t *LsTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||
a, bad := decodeArgs[lsToolArgs](args, "ls")
|
||
if bad != nil {
|
||
return *bad, nil
|
||
}
|
||
full, err := resolveWithin(t.Root, a.Path)
|
||
if err != nil {
|
||
return errorResult("ls: " + err.Error()), nil
|
||
}
|
||
info, err := os.Stat(full)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return errorResult(fmt.Sprintf("ls: %q does not exist", a.Path)), nil
|
||
}
|
||
return errorResult(fmt.Sprintf("ls: cannot stat %q: %v", a.Path, err)), nil
|
||
}
|
||
if !info.IsDir() {
|
||
return errorResult(fmt.Sprintf("ls: %q is not a directory", a.Path)), nil
|
||
}
|
||
entries, err := os.ReadDir(full)
|
||
if err != nil {
|
||
return errorResult(fmt.Sprintf("ls: cannot read %q: %v", a.Path, err)), nil
|
||
}
|
||
|
||
var dirs, files []string
|
||
for _, e := range entries {
|
||
if e.IsDir() {
|
||
dirs = append(dirs, e.Name()+"/")
|
||
} else {
|
||
files = append(files, e.Name())
|
||
}
|
||
}
|
||
sort.Strings(dirs)
|
||
sort.Strings(files)
|
||
lines := append(dirs, files...)
|
||
|
||
label := a.Path
|
||
if label == "" {
|
||
label = "."
|
||
}
|
||
msg := fmt.Sprintf("%s (%d dir(s), %d file(s))", label, len(dirs), len(files))
|
||
if len(lines) > 0 {
|
||
msg += "\n" + strings.Join(lines, "\n")
|
||
}
|
||
return agentcore.AgentToolResult{
|
||
Content: agentcore.ContentList{agentcore.NewTextContent(msg)},
|
||
Details: map[string]any{"dirs": len(dirs), "files": len(files)},
|
||
}, nil
|
||
}
|