Vectrum
Sign In
337 lines · 10.6 KB · Go
package tools

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"image"
	_ "image/gif"
	_ "image/jpeg"
	_ "image/png"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/johannSo/codey/internal/llm"
)

// 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.
//
// The tool it returns is a BlockTool: a PNG, JPEG, GIF or WebP comes back as an
// image the model can look at, everything else as numbered text.
func NewRead(root string, track ReadTracker, extra ...string) Tool {
	return readTool{Tool: newReadText(root, track, extra), root: root, track: track, extra: extra}
}

func newReadText(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. An image file (png, jpeg, gif, webp)
is returned as a picture you can see — use this to look at a screenshot you
took.`),
		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
		})
}

// readTool is read_file with the image branch on top of the text tool.
type readTool struct {
	Tool
	root  string
	track ReadTracker
	extra []string
}

// maxImageBytes is the most read_file will hand to the model as an image. The
// API's own limit is a few megabytes; a screenshot is far below that, and a
// file above it is not something to spend the context window on unreduced.
const maxImageBytes = 4 << 20

// ExecuteBlocks routes an image path to the image reader and anything else to
// the ordinary text reader.
func (r readTool) ExecuteBlocks(ctx context.Context, raw json.RawMessage) (Output, error) {
	var in ReadArgs
	if len(raw) > 0 {
		if err := json.Unmarshal(raw, &in); err != nil {
			return Output{}, fmt.Errorf("invalid arguments: %w", err)
		}
	}
	mediaType, isImage := imageMediaType(in.Path)
	if !isImage {
		text, err := r.Tool.Execute(ctx, raw)
		return Output{Text: text}, err
	}

	path, err := resolveWith(r.root, r.extra, in.Path)
	if err != nil {
		return Output{}, err
	}
	info, err := os.Stat(path)
	switch {
	case os.IsNotExist(err):
		return Output{}, notFound(in.Path, r.extra)
	case err != nil:
		return Output{}, err
	case info.IsDir():
		return Output{}, fmt.Errorf("%s is a directory, not a file; use glob or grep to look inside it", in.Path)
	case info.Size() > maxImageBytes:
		return Output{}, fmt.Errorf("%s is %s, too large to send as an image (the cap is %s); downscale or crop it first, for example with ImageMagick or a browser screenshot at a smaller viewport",
			in.Path, humanBytes(info.Size()), humanBytes(maxImageBytes))
	}
	data, err := os.ReadFile(path)
	if err != nil {
		return Output{}, err
	}
	if r.track != nil {
		r.track.MarkRead(path, info.ModTime())
	}

	caption := fmt.Sprintf("%s: %s image, %s", in.Path, imageFormatName(mediaType), humanBytes(int64(len(data))))
	if cfg, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil {
		caption = fmt.Sprintf("%s: %s image, %d×%d, %s", in.Path, imageFormatName(mediaType), cfg.Width, cfg.Height, humanBytes(int64(len(data))))
	}
	return Output{
		Text: caption,
		Blocks: []llm.ContentBlock{
			llm.TextBlock(caption),
			llm.ImageBlock(mediaType, base64.StdEncoding.EncodeToString(data)),
		},
	}, nil
}

// imageMediaType maps a file name to the media type the API accepts for it.
// SVG is deliberately not here: it is text, and the API would not take it as
// an image anyway.
func imageMediaType(path string) (string, bool) {
	switch strings.ToLower(filepath.Ext(path)) {
	case ".png":
		return "image/png", true
	case ".jpg", ".jpeg":
		return "image/jpeg", true
	case ".gif":
		return "image/gif", true
	case ".webp":
		return "image/webp", true
	}
	return "", false
}

func imageFormatName(mediaType string) string {
	return strings.ToUpper(strings.TrimPrefix(mediaType, "image/"))
}

func humanBytes(n int64) string {
	switch {
	case n >= 1<<20:
		return fmt.Sprintf("%.1f MB", float64(n)/(1<<20))
	case n >= 1<<10:
		return fmt.Sprintf("%d KB", n>>10)
	}
	return fmt.Sprintf("%d bytes", n)
}

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