first commit
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user