Files
2026-08-14 23:41:57 +08:00

493 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}