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