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 // ReadTracker records that a file was read, so edit_file (Phase 2) can refuse // to edit a file the model has not looked at. agent.State implements it. type ReadTracker interface { MarkRead(path string, mod time.Time) } // 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) }