first commit
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
// This file implements the read tool (US-015): read a file's contents by path,
|
||||
// with optional line offset/limit, numbered output, and large-file truncation.
|
||||
// Paths are resolved against a Root and rejected if they escape it (path
|
||||
// traversal guard) or do not exist.
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// readToolMaxLines caps how many lines a single read returns before truncating
|
||||
// (protects the model's context from huge files). Callers page with offset.
|
||||
const readToolMaxLines = 2000
|
||||
|
||||
// readToolMaxLineLen caps how many bytes of a single line are returned; longer
|
||||
// lines are truncated with a marker.
|
||||
const readToolMaxLineLen = 2000
|
||||
|
||||
// scanBufInit is the initial per-line scanner buffer (it grows on demand up to
|
||||
// the max). readScanBufMax is generous — a read may page through a file with
|
||||
// very long lines (minified JS, JSON) that must not error out mid-read.
|
||||
const (
|
||||
scanBufInit = 64 * 1024
|
||||
readScanBufMax = 16 * 1024 * 1024
|
||||
grepScanBufMax = 1 * 1024 * 1024
|
||||
)
|
||||
|
||||
// filePerm / dirPerm are the modes new files and parent directories are created
|
||||
// with by the write/edit tools (standard non-executable file, traversable dir).
|
||||
const (
|
||||
filePerm os.FileMode = 0o644
|
||||
dirPerm os.FileMode = 0o755
|
||||
)
|
||||
|
||||
// ReadTool reads text files under Root. It is the first concrete AgentTool.
|
||||
type ReadTool struct {
|
||||
// Root is the directory that bounds all reads. A path resolving outside Root
|
||||
// is rejected. Empty Root defaults to the current working directory.
|
||||
Root string
|
||||
// ExtraRoots are additional trusted directories a read may target even though
|
||||
// they lie outside Root. It exists for the skills directory: pigo advertises
|
||||
// each skill's absolute SKILL.md path in the system prompt and tells the model
|
||||
// to read it, so the read tool must permit those paths (they are otherwise
|
||||
// outside the workspace and rejected).
|
||||
ExtraRoots []string
|
||||
}
|
||||
|
||||
// readToolArgs is the decoded argument shape for ReadTool.
|
||||
type readToolArgs struct {
|
||||
// Path is the file to read, relative to Root (or absolute within Root).
|
||||
Path string `json:"path"`
|
||||
// Offset is the 1-based line to start reading from. 0/1 both mean line 1.
|
||||
Offset int `json:"offset,omitempty"`
|
||||
// Limit is the maximum number of lines to return. 0 means the default cap.
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// Name implements AgentTool.
|
||||
func (t *ReadTool) Name() string { return "read" }
|
||||
|
||||
// Description implements AgentTool.
|
||||
func (t *ReadTool) Description() string {
|
||||
return "Read a text file's contents by path, with optional line offset/limit. " +
|
||||
"Output is line-numbered; very large files are truncated."
|
||||
}
|
||||
|
||||
// Schema implements AgentTool.
|
||||
func (t *ReadTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "File path to read, relative to the workspace root."},
|
||||
"offset": {"type": "integer", "description": "1-based line number to start from.", "minimum": 0},
|
||||
"limit": {"type": "integer", "description": "Maximum number of lines to return.", "minimum": 0}
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
}`)
|
||||
}
|
||||
|
||||
// ExecutionMode implements AgentTool. Reads are side-effect free → parallel.
|
||||
func (t *ReadTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionParallel
|
||||
}
|
||||
|
||||
// resolvePath resolves p against Root (or any ExtraRoots) via the shared
|
||||
// resolveWithin boundary policy, so every file tool enforces the same
|
||||
// workspace-escape guard while the read tool can also reach trusted extra roots.
|
||||
func (t *ReadTool) resolvePath(p string) (string, error) {
|
||||
if len(t.ExtraRoots) == 0 {
|
||||
return resolveWithin(t.Root, p)
|
||||
}
|
||||
return resolveWithinAny(append([]string{t.Root}, t.ExtraRoots...), p)
|
||||
}
|
||||
|
||||
// Execute implements AgentTool. It never returns a Go error for a read failure
|
||||
// (bad path, missing file); those are encoded as error results so the model can
|
||||
// react. The returned error is reserved for argument decode failures.
|
||||
func (t *ReadTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
a, bad := decodeArgs[readToolArgs](args, "read")
|
||||
if bad != nil {
|
||||
return *bad, nil
|
||||
}
|
||||
if a.Path == "" {
|
||||
return errorResult("read: path is required"), nil
|
||||
}
|
||||
full, err := t.resolvePath(a.Path)
|
||||
if err != nil {
|
||||
return errorResult("read: " + err.Error()), nil
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errorResult(fmt.Sprintf("read: file %q does not exist", a.Path)), nil
|
||||
}
|
||||
return errorResult(fmt.Sprintf("read: cannot stat %q: %v", a.Path, err)), nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return errorResult(fmt.Sprintf("read: %q is a directory, not a file", a.Path)), nil
|
||||
}
|
||||
|
||||
f, err := os.Open(full)
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("read: cannot open %q: %v", a.Path, err)), nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
text, truncated := readNumbered(f, a.Offset, a.Limit)
|
||||
if truncated {
|
||||
text += fmt.Sprintf("\n... (output truncated at %d lines; use offset to read more)", readToolMaxLines)
|
||||
}
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(text)}}, nil
|
||||
}
|
||||
|
||||
// readNumbered reads lines from r starting at 1-based offset, returning at most
|
||||
// limit lines (capped at readToolMaxLines), each prefixed with its line number.
|
||||
// The bool reports whether the output was truncated by the cap.
|
||||
func readNumbered(r io.Reader, offset, limit int) (string, bool) {
|
||||
if offset < 1 {
|
||||
offset = 1
|
||||
}
|
||||
max := limit
|
||||
if max <= 0 || max > readToolMaxLines {
|
||||
max = readToolMaxLines
|
||||
}
|
||||
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, scanBufInit), readScanBufMax)
|
||||
var b strings.Builder
|
||||
lineNo := 0
|
||||
emitted := 0
|
||||
truncated := false
|
||||
for sc.Scan() {
|
||||
lineNo++
|
||||
if lineNo < offset {
|
||||
continue
|
||||
}
|
||||
if emitted >= max {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
line := sc.Text()
|
||||
if len(line) > readToolMaxLineLen {
|
||||
line = line[:readToolMaxLineLen] + "… (line truncated)"
|
||||
}
|
||||
fmt.Fprintf(&b, "%6d\t%s\n", lineNo, line)
|
||||
emitted++
|
||||
}
|
||||
return b.String(), truncated
|
||||
}
|
||||
Reference in New Issue
Block a user