Vectrum
Sign In
135 lines · 3.8 KB · Go
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)
}