// 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 }