1// Package tools implements zot's built-in tools: read, write, edit, bash.
2package tools
3
4import (
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "strings"
12
13 "github.com/patriceckhart/zot/internal/core"
14 "github.com/patriceckhart/zot/internal/provider"
15)
16
17const (
18 maxReadLines = 2000
19 maxReadBytes = 50 * 1024
20)
21
22// ReadTool reads file contents from disk.
23type ReadTool struct {
24 CWD string
25 Sandbox *Sandbox // when locked, confines reads to the sandbox root
26}
27
28type readArgs struct {
29 Path string `json:"path"`
30 Offset int `json:"offset,omitempty"`
31 Limit int `json:"limit,omitempty"`
32}
33
34const readSchema = `{
35 "type":"object",
36 "properties":{
37 "path":{"type":"string","description":"Path to the file to read (relative or absolute)"},
38 "offset":{"type":"integer","description":"Line number to start reading from (1-indexed)"},
39 "limit":{"type":"integer","description":"Maximum number of lines to read"}
40 },
41 "required":["path"],
42 "additionalProperties":false
43}`
44
45func (t *ReadTool) Name() string { return "read" }
46func (t *ReadTool) Description() string {
47 return "Read the contents of a text file or an image (png/jpg/jpeg/gif/webp). Large files are truncated."
48}
49func (t *ReadTool) Schema() json.RawMessage { return json.RawMessage(readSchema) }
50
51func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
52 var a readArgs
53 if err := json.Unmarshal(raw, &a); err != nil {
54 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
55 }
56 if a.Path == "" {
57 return core.ToolResult{}, fmt.Errorf("path is required")
58 }
59 path := resolvePath(t.CWD, a.Path)
60 if err := t.Sandbox.CheckPath(path); err != nil {
61 return core.ToolResult{}, err
62 }
63
64 info, err := os.Stat(path)
65 if err != nil {
66 return core.ToolResult{}, err
67 }
68 if info.IsDir() {
69 return core.ToolResult{}, fmt.Errorf("%s is a directory", path)
70 }
71
72 // Image handling.
73 if mime := imageMIME(path); mime != "" {
74 data, err := os.ReadFile(path)
75 if err != nil {
76 return core.ToolResult{}, err
77 }
78 return core.ToolResult{
79 Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}},
80 }, nil
81 }
82
83 f, err := os.Open(path)
84 if err != nil {
85 return core.ToolResult{}, err
86 }
87 defer f.Close()
88
89 // Read up to maxReadBytes and also line-limit.
90 limited := io.LimitReader(f, int64(maxReadBytes)+1)
91 data, err := io.ReadAll(limited)
92 if err != nil {
93 return core.ToolResult{}, err
94 }
95 truncBytes := len(data) > maxReadBytes
96 if truncBytes {
97 data = data[:maxReadBytes]
98 }
99
100 if looksBinary(data) {
101 return core.ToolResult{}, fmt.Errorf("%s looks binary; refusing to read as text", a.Path)
102 }
103
104 lines := strings.Split(string(data), "\n")
105 // Trim trailing empty line from final \n.
106 if n := len(lines); n > 0 && lines[n-1] == "" {
107 lines = lines[:n-1]
108 }
109
110 start := 0
111 if a.Offset > 0 {
112 start = a.Offset - 1
113 if start > len(lines) {
114 start = len(lines)
115 }
116 }
117 end := len(lines)
118 if a.Limit > 0 && start+a.Limit < end {
119 end = start + a.Limit
120 }
121 selected := lines[start:end]
122
123 truncLines := false
124 if len(selected) > maxReadLines {
125 selected = selected[:maxReadLines]
126 truncLines = true
127 }
128
129 // Render with 1-indexed line numbers, cat -n style.
130 var sb strings.Builder
131 for i, line := range selected {
132 fmt.Fprintf(&sb, "%6d\t%s\n", start+i+1, line)
133 }
134 if truncLines {
135 sb.WriteString(fmt.Sprintf("... [truncated at %d lines]\n", maxReadLines))
136 }
137 if truncBytes {
138 sb.WriteString(fmt.Sprintf("... [truncated at %d bytes]\n", maxReadBytes))
139 }
140
141 return core.ToolResult{
142 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
143 Details: map[string]any{
144 "path": path,
145 "lines_truncated": truncLines,
146 "bytes_truncated": truncBytes,
147 "total_lines": len(lines),
148 },
149 }, nil
150}
151
152func resolvePath(cwd, p string) string {
153 if filepath.IsAbs(p) {
154 return p
155 }
156 if cwd == "" {
157 cwd, _ = os.Getwd()
158 }
159 return filepath.Join(cwd, p)
160}
161
162func imageMIME(path string) string {
163 switch strings.ToLower(filepath.Ext(path)) {
164 case ".png":
165 return "image/png"
166 case ".jpg", ".jpeg":
167 return "image/jpeg"
168 case ".gif":
169 return "image/gif"
170 case ".webp":
171 return "image/webp"
172 }
173 return ""
174}
175
176// looksBinary returns true if the buffer contains a NUL byte in its first 8 KiB.
177func looksBinary(b []byte) bool {
178 n := len(b)
179 if n > 8192 {
180 n = 8192
181 }
182 for i := 0; i < n; i++ {
183 if b[i] == 0 {
184 return true
185 }
186 }
187 return false
188}