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