package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// defaultReadLimit is how many lines read_file returns when the model does not
// ask for a window.
const defaultReadLimit = 2000
// ReadRecord is when a file was last read and what its mtime was at the time.
type ReadRecord struct {
At time.Time
Mod time.Time
}
// ReadTracker records that a file was read.
type ReadTracker interface {
MarkRead(path string, mod time.Time)
}
// FileTracker additionally answers what was read, which is what lets edit_file
// refuse to edit a file the model has not looked at. agent.State implements it.
type FileTracker interface {
ReadTracker
LastRead(path string) (ReadRecord, bool)
}
// ReadArgs is the read_file input.
type ReadArgs struct {
Path string `json:"path" jsonschema:"description=Path to the file, absolute or relative to the working root"`
Offset int `json:"offset,omitempty" jsonschema:"description=1-indexed line to start at; omit to start at the beginning"`
Limit int `json:"limit,omitempty" jsonschema:"description=Maximum number of lines to return; defaults to 2000"`
}
// NewRead builds the read_file tool. root anchors relative paths; track may be
// nil; extra names additional directories that may be read but never written,
// such as a skill's own directory outside the working root.
func NewRead(root string, track ReadTracker, extra ...string) Tool {
return New("read_file", strings.TrimSpace(`
Read a file from disk. Returns the contents with 1-indexed line numbers, in the
form " 12\tcode". Use offset and limit to page through a large file rather
than reading it whole. Line numbers are for your reference only — never include
them in text you write back to the file.`),
func(_ context.Context, in ReadArgs) (string, error) {
path, err := resolveWith(root, extra, in.Path)
if err != nil {
return "", err
}
info, err := os.Stat(path)
switch {
case os.IsNotExist(err):
return "", notFound(in.Path, extra)
case err != nil:
return "", err
case info.IsDir():
return "", fmt.Errorf("%s is a directory, not a file; use glob or grep to look inside it", in.Path)
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
if track != nil {
track.MarkRead(path, info.ModTime())
}
if len(data) == 0 {
return fmt.Sprintf("%s is empty (0 bytes).", in.Path), nil
}
lines := splitLines(string(data))
start := in.Offset
if start < 1 {
start = 1
}
if start > len(lines) {
return "", fmt.Errorf("offset %d is past the end of %s, which has %d lines", in.Offset, in.Path, len(lines))
}
limit := in.Limit
if limit <= 0 {
limit = defaultReadLimit
}
end := start + limit - 1
if end > len(lines) {
end = len(lines)
}
var b strings.Builder
for i := start; i <= end; i++ {
fmt.Fprintf(&b, "%6d\t%s\n", i, capLine(lines[i-1]))
}
if end < len(lines) {
fmt.Fprintf(&b, "\n(showing lines %d-%d of %d; call read_file again with offset=%d for more)", start, end, len(lines), end+1)
}
return b.String(), nil
})
}
// splitLines splits on \n and drops the trailing empty element a final newline
// produces, so a 3-line file reports 3 lines.
func splitLines(s string) []string {
s = strings.ReplaceAll(s, "\r\n", "\n")
lines := strings.Split(s, "\n")
if n := len(lines); n > 0 && lines[n-1] == "" {
lines = lines[:n-1]
}
return lines
}
// notFound reports a missing file, naming the skill directories when there are
// any. A relative path anchors to the working root, so a model following a
// skill's own wording asks for "references/note.md" and misses; this error is
// the only place it can learn where that file actually lives.
func notFound(path string, extra []string) error {
if len(extra) == 0 || filepath.IsAbs(path) {
return fmt.Errorf("no such file: %s", path)
}
return fmt.Errorf("no such file: %s — relative paths are resolved against the working root. "+
"If this belongs to a skill, join it onto that skill's directory: %s",
path, strings.Join(extra, ", "))
}
// resolve anchors a relative path to root and refuses anything outside it.
func resolve(root, path string) (string, error) {
return resolveWith(root, nil, path)
}
// resolveWith is resolve with additional permitted roots. It is used by
// read_file, whose extra roots let a skill's own directory be read without
// widening what edit_file or write_file may touch.
func resolveWith(root string, extra []string, path string) (string, error) {
if path == "" {
return "", fmt.Errorf("path is required")
}
return jailWith(root, extra, resolveBase(root, path))
}
// resolveBase is resolve for the search tools, where an omitted path means the
// working root rather than an error. It does not jail; callers that take a
// path from the model must.
func resolveBase(root, path string) string {
if root == "" {
root = "."
}
if path == "" {
return root
}
if filepath.IsAbs(path) {
return filepath.Clean(path)
}
return filepath.Join(root, path)
}
// jail refuses paths outside the working root. Symlinks are resolved first, on
// the existing part of the path, so a link pointing out of the tree cannot be
// used to step around the check. It is cheap and catches a lot; it is not a
// substitute for a sandbox, since bash can still cd anywhere.
func jail(root, path string) (string, error) {
return jailWith(root, nil, path)
}
// jailWith is jail with additional permitted roots, used by the read-only
// tools so a skill can read the files it ships with. Each extra root gets the
// same symlink resolution as the working root, so an extra root cannot be used
// to step sideways into the rest of the filesystem.
func jailWith(root string, extra []string, path string) (string, error) {
if root == "" {
return path, nil
}
// A file being created does not exist yet, so resolve the deepest existing
// ancestor and re-attach the rest.
probe, rest := path, ""
for {
if resolved, err := filepath.EvalSymlinks(probe); err == nil {
path = filepath.Join(resolved, rest)
break
}
parent := filepath.Dir(probe)
if parent == probe {
path = filepath.Clean(path)
break
}
rest = filepath.Join(filepath.Base(probe), rest)
probe = parent
}
realRoot := realpath(root)
if contains(realRoot, path) {
return path, nil
}
for _, e := range extra {
if e != "" && contains(realpath(e), path) {
return path, nil
}
}
if len(extra) > 0 {
return "", fmt.Errorf("%s is outside the working root %s and every skill directory; codey only touches files under the root", path, realRoot)
}
return "", fmt.Errorf("%s is outside the working root %s; codey only touches files under the root", path, realRoot)
}
// realpath resolves symlinks, falling back to a lexical clean for a path that
// does not exist.
func realpath(p string) string {
if resolved, err := filepath.EvalSymlinks(p); err == nil {
return resolved
}
return filepath.Clean(p)
}
// contains reports whether path is root or sits beneath it.
func contains(root, path string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}