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.
func NewRead(root string, track ReadTracker) 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 := resolve(root, in.Path)
if err != nil {
return "", err
}
info, err := os.Stat(path)
switch {
case os.IsNotExist(err):
return "", fmt.Errorf("no such file: %s", in.Path)
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
}
// resolve anchors a relative path to root, requiring one. The real path jail
// lands in Phase 6.
func resolve(root, path string) (string, error) {
if path == "" {
return "", fmt.Errorf("path is required")
}
return resolveBase(root, path), nil
}
// resolveBase is resolve for the search tools, where an omitted path means the
// working root rather than an error.
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)
}