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/packages/core"
14 "github.com/patriceckhart/zot/packages/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 jailed, 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 = `{"type":"object","properties":{"path":{"type":"string"},"offset":{"type":"integer"},"limit":{"type":"integer"}},"required":["path"]}`
35
36func (t *ReadTool) Name() string { return "read" }
37func (t *ReadTool) Description() string {
38 return "Read a file. Images (png/jpg/gif/webp) return inline."
39}
40func (t *ReadTool) Schema() json.RawMessage { return json.RawMessage(readSchema) }
41
42func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
43 var a readArgs
44 if err := json.Unmarshal(raw, &a); err != nil {
45 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
46 }
47 if a.Path == "" {
48 return core.ToolResult{}, fmt.Errorf("path is required")
49 }
50 path := resolvePath(t.CWD, a.Path)
51 if err := t.Sandbox.CheckPath(path); err != nil {
52 return core.ToolResult{}, err
53 }
54 // What the model sees in errors: relative to the sandbox root when
55 // jailed, so absolute paths stay out of the context window.
56 shown := t.Sandbox.DisplayPath(path, a.Path)
57
58 info, err := os.Stat(path)
59 if err != nil {
60 return core.ToolResult{}, err
61 }
62 if info.IsDir() {
63 return core.ToolResult{}, fmt.Errorf("%s is a directory", shown)
64 }
65
66 // Image handling.
67 if mime := imageMIME(path); mime != "" {
68 data, err := os.ReadFile(path)
69 if err != nil {
70 return core.ToolResult{}, err
71 }
72 return core.ToolResult{
73 Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}},
74 }, nil
75 }
76
77 f, err := os.Open(path)
78 if err != nil {
79 return core.ToolResult{}, err
80 }
81 defer f.Close()
82
83 // Read up to maxReadBytes and also line-limit.
84 limited := io.LimitReader(f, int64(maxReadBytes)+1)
85 data, err := io.ReadAll(limited)
86 if err != nil {
87 return core.ToolResult{}, err
88 }
89 truncBytes := len(data) > maxReadBytes
90 if truncBytes {
91 data = data[:maxReadBytes]
92 }
93
94 if looksBinary(data) {
95 return core.ToolResult{}, fmt.Errorf("%s looks binary; refusing to read as text", a.Path)
96 }
97
98 lines := strings.Split(string(data), "\n")
99 // Trim trailing empty line from final \n.
100 if n := len(lines); n > 0 && lines[n-1] == "" {
101 lines = lines[:n-1]
102 }
103
104 start := 0
105 if a.Offset > 0 {
106 start = a.Offset - 1
107 if start > len(lines) {
108 start = len(lines)
109 }
110 }
111 end := len(lines)
112 if a.Limit > 0 && start+a.Limit < end {
113 end = start + a.Limit
114 }
115 selected := lines[start:end]
116
117 truncLines := false
118 if len(selected) > maxReadLines {
119 selected = selected[:maxReadLines]
120 truncLines = true
121 }
122
123 // Raw file contents go to the model. We deliberately DON'T
124 // prepend line numbers here: they'd inflate the token count by
125 // ~15-20% on typical source files (7 bytes per line, every
126 // line, every time the file gets re-sent as context on later
127 // turns) and the model doesn't need them — edit goes through
128 // exact-match text replacement, not line ranges.
129 //
130 // The TUI renders its own gutter using the start offset stored
131 // in Details, so the on-screen view still looks like cat -n.
132 var sb strings.Builder
133 for _, line := range selected {
134 sb.WriteString(line)
135 sb.WriteByte('\n')
136 }
137 if truncLines || truncBytes {
138 sb.WriteString("\n")
139 }
140 if truncLines {
141 sb.WriteString(fmt.Sprintf("... [truncated at %d lines]\n", maxReadLines))
142 }
143 if truncBytes {
144 sb.WriteString(fmt.Sprintf("... [truncated at %d bytes]\n", maxReadBytes))
145 }
146
147 return core.ToolResult{
148 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
149 Details: map[string]any{
150 "path": path,
151 "start_line": start + 1, // 1-indexed; TUI draws the gutter
152 "lines_truncated": truncLines,
153 "bytes_truncated": truncBytes,
154 "total_lines": len(lines),
155 },
156 }, nil
157}
158
159func resolvePath(cwd, p string) string {
160 if filepath.IsAbs(p) {
161 return p
162 }
163 if cwd == "" {
164 cwd, _ = os.Getwd()
165 }
166 return filepath.Join(cwd, p)
167}
168
169func imageMIME(path string) string {
170 switch strings.ToLower(filepath.Ext(path)) {
171 case ".png":
172 return "image/png"
173 case ".jpg", ".jpeg":
174 return "image/jpeg"
175 case ".gif":
176 return "image/gif"
177 case ".webp":
178 return "image/webp"
179 }
180 return ""
181}
182
183// looksBinary returns true if the buffer contains a NUL byte in its first 8 KiB.
184func looksBinary(b []byte) bool {
185 n := len(b)
186 if n > 8192 {
187 n = 8192
188 }
189 for i := 0; i < n; i++ {
190 if b[i] == 0 {
191 return true
192 }
193 }
194 return false
195}