返回 DeepSeek-Reasonix
readfile.go
根目录 / internal / tool / builtin / readfile.go
1 // Package builtin provides Reasonix's compile-time built-in tools. Each tool
2 // self-registers via init(); main blank-imports this package to wire them in.
3 package builtin
4
5 import (
6 "bufio"
7 "bytes"
8 "context"
9 "crypto/sha256"
10 "encoding/hex"
11 "encoding/json"
12 "fmt"
13 "io"
14 "path/filepath"
15 "strconv"
16 "strings"
17
18 "golang.org/x/text/transform"
19
20 fileenc "reasonix/internal/fileutil/encoding"
21 "reasonix/internal/tool"
22 )
23
24 const (
25 readFileBinaryPeek = 8 * 1024 // bytes scanned for NUL before reading further
26 readFileDetectSample = 256 * 1024 // bytes sampled for encoding detection before streaming
27 readFileMaxLineBytes = 1024 * 1024
28 readFileMaxFormattedBytes = 8 << 20
29 )
30
31 func init() { tool.RegisterBuiltin(readFile{}) }
32
33 // readFile reads a text file. workDir, when non-empty, is the directory a
34 // relative path is resolved against (see resolveIn). paths maps session-scoped
35 // external read aliases to local roots without changing the model-visible tool
36 // schema. forbidRoots lists directories the tool may not read from (resolved,
37 // absolute paths).
38 type readFile struct {
39 workDir string
40 paths *PathResolver
41 forbidRoots []string
42 // overlay, when non-nil, serves content from the host transport (unsaved
43 // editor buffers) before falling back to disk. Consulted only after path
44 // resolution and read confinement, and never for external alias paths.
45 overlay FileOverlay
46 captured *tool.ReadResultSource
47 }
48
49 const (
50 readFileDefaultLimit = 2000 // lines returned when limit is unset
51 )
52
53 // readFileParams is one validated read_file call with defaults applied.
54 type readFileParams struct {
55 Path string
56 Intent tool.ReadIntent
57 WindowGiven bool
58 Offset int
59 Limit int
60 }
61
62 const (
63 readFileEmptyOutput = "(empty file)"
64 readFilePastEOFTail = " is past EOF — file has "
65 )
66
67 // readWindowGiven reports whether the call named an explicit line window.
68 func readWindowGiven(args json.RawMessage) bool {
69 var fields map[string]json.RawMessage
70 if err := json.Unmarshal(args, &fields); err != nil {
71 return false
72 }
73 _, offset := fields["offset"]
74 _, limit := fields["limit"]
75 return offset || limit
76 }
77
78 // parseReadFileParams validates one read_file call and applies the documented
79 // defaults, so Execute and ReadEnvelope agree on what was requested.
80 func parseReadFileParams(args json.RawMessage) (readFileParams, error) {
81 var p struct {
82 Path string `json:"path"`
83 Intent string `json:"intent,omitempty"`
84 Offset int `json:"offset,omitempty"`
85 Limit int `json:"limit,omitempty"`
86 }
87 if err := json.Unmarshal(args, &p); err != nil {
88 return readFileParams{}, fmt.Errorf("invalid args: %w", err)
89 }
90 if p.Path == "" {
91 return readFileParams{}, fmt.Errorf("path is required")
92 }
93 windowGiven := readWindowGiven(args)
94 intent, err := readIntentFor(p.Intent, windowGiven)
95 if err != nil {
96 return readFileParams{}, err
97 }
98 if p.Offset < 0 {
99 p.Offset = 0
100 }
101 if p.Limit <= 0 {
102 p.Limit = readFileDefaultLimit
103 }
104 return readFileParams{Path: p.Path, Intent: intent, WindowGiven: windowGiven, Offset: p.Offset, Limit: p.Limit}, nil
105 }
106
107 // readIntentFor resolves the effective read intent and rejects combinations
108 // that would leave the caller unsure which promise the call made.
109 func readIntentFor(explicit string, windowGiven bool) (tool.ReadIntent, error) {
110 switch tool.ReadIntent(strings.TrimSpace(explicit)) {
111 case "":
112 if windowGiven {
113 return tool.ReadIntentRange, nil
114 }
115 return tool.ReadIntentInspect, nil
116 case tool.ReadIntentInspect:
117 return tool.ReadIntentInspect, nil
118 case tool.ReadIntentRange:
119 if !windowGiven {
120 return "", fmt.Errorf("intent=range requires an explicit offset or limit; pass the window to read, or use intent=inspect for a bounded preview")
121 }
122 return tool.ReadIntentRange, nil
123 case tool.ReadIntentFull:
124 // Kept as a compatibility hint. Every call remains one bounded window;
125 // the host no longer creates a whole-file debt or completion gate.
126 return tool.ReadIntentFull, nil
127 default:
128 return "", fmt.Errorf("intent must be inspect, range, or full (got %q)", explicit)
129 }
130 }
131
132 func (readFile) Name() string { return "read_file" }
133
134 func (readFile) Description() string {
135 return "Read one bounded text window with optional line offset/limit. Output prefixes each line with its 1-based number. Any successful window observes the current file version for later structured edits. Use the next-window hint to page only when more content is useful. Legacy intent and cursor fields are accepted as navigation hints and never create a whole-file completion requirement."
136 }
137
138 func (readFile) Schema() json.RawMessage {
139 return json.RawMessage(`{
140 "type":"object",
141 "properties":{
142 "path":{"type":"string","description":"File path"},
143 "intent":{"type":"string","enum":["inspect","range","full"],"description":"Compatibility hint. Every value reads only this bounded window and creates no whole-file obligation."},
144 "cursor":{"type":"string","description":"Optional continuation cursor from a prior result. Invalid legacy cursors should be replaced with an explicit offset and limit."},
145 "offset":{"type":"integer","description":"0-based line offset to start reading from (default 0)","minimum":0},
146 "limit":{"type":"integer","description":"Maximum lines to return (default 2000)","minimum":1}
147 },
148 "required":["path"]
149 }`)
150 }
151
152 func (readFile) ReadOnly() bool { return true }
153
154 // ReadEnvelope reports what one read_file call delivered. The source identity
155 // comes from the store that actually served the content, the snapshot stays
156 // constant across the pages of one logical read, and the window digest covers
157 // only this page's delivered lines. read_id, result_ref and workspace_id are
158 // host identity the agent fills in.
159 func (r readFile) ReadEnvelope(ctx context.Context, args json.RawMessage, output string) (tool.ReadResultEnvelope, bool) {
160 p, err := parseReadFileParams(args)
161 if err != nil {
162 return tool.ReadResultEnvelope{}, false
163 }
164 rp := resolveReadablePath(r.workDir, p.Path, r.paths)
165 env := tool.ReadResultEnvelope{
166 ProtocolVersion: tool.ReadResultProtocolVersion,
167 Source: tool.ReadResultSource{CanonicalPath: rp.Path},
168 Intent: p.Intent,
169 }
170 if p.WindowGiven {
171 requested := tool.ReadRange{Start: p.Offset, End: p.Offset + p.Limit}
172 env.RequestedRange = &requested
173 }
174
175 // The store that served the content owns the identity: an unsaved editor
176 // buffer must never be proven by the disk file's identity.
177 if r.captured != nil {
178 env.Source = *r.captured
179 }
180 env.Source.Snapshot = tool.SourceSnapshot(env.Source.Kind, rp.Path, env.Source.Identity)
181
182 window, hasWindow := tool.ParseReadWindow(output)
183 if hasWindow {
184 env.DeliveredRanges = []tool.ReadRange{window.Range()}
185 env.WindowDigest = tool.WindowDigest(rp.Path, window)
186 }
187 trailer := tool.ParseReadTrailer(output)
188 env.HasMore = trailer.HasMore
189 env.EOF = !trailer.HasMore
190 switch {
191 case trailer.LocalSafety:
192 env.SourceCut = tool.ReadCutSafetyPage
193 case trailer.HasMore:
194 env.SourceCut = tool.ReadCutPageLimit
195 }
196 if env.EOF {
197 if end, ok := readSourceEnd(output, window, hasWindow); ok {
198 env.SourceEnd = &end
199 }
200 }
201 if trailer.HasMore {
202 env.NextCursor = tool.EncodeReadCursor(tool.ReadCursor{
203 Path: rp.Path,
204 Snapshot: env.Source.Snapshot,
205 NextStart: trailer.NextOffset,
206 })
207 }
208 return env, true
209 }
210
211 // overlayText mirrors Execute's overlay routing so the envelope names the same
212 // store that produced the delivered bytes.
213 func (r readFile) overlayText(ctx context.Context, rp ResolvedPath) (string, bool) {
214 if r.overlay == nil || rp.External || !filepath.IsAbs(rp.Path) {
215 return "", false
216 }
217 return r.overlay.ReadTextFile(ctx, rp.Path)
218 }
219
220 func digestText(content string) string {
221 sum := sha256.Sum256([]byte(content))
222 return hex.EncodeToString(sum[:])
223 }
224
225 // readSourceEnd recovers the source's zero-based end line index from the
226 // reader's own result text: a complete window ends at its last line, and the
227 // empty-file / past-EOF markers state the count directly.
228 func readSourceEnd(output string, window tool.ReadWindow, hasWindow bool) (int, bool) {
229 if hasWindow {
230 return window.Range().End, true
231 }
232 trimmed := strings.TrimSpace(output)
233 if trimmed == readFileEmptyOutput {
234 return 0, true
235 }
236 if rest, ok := strings.CutPrefix(trimmed, "(offset "); ok {
237 if _, tail, found := strings.Cut(rest, readFilePastEOFTail); found {
238 if n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimSpace(tail), " lines)")); err == nil && n >= 0 {
239 return n, true
240 }
241 }
242 }
243 return 0, false
244 }
245
246 // SnipHint front-loads file content: the most relevant lines are near the top,
247 // so keep a generous head and a short tail when an old read is shortened.
248 func (readFile) SnipHint() tool.SnipHint {
249 return tool.SnipHint{Head: 120, Tail: 12, HeadChars: 12000, TailChars: 2000}
250 }
251
252 func (r readFile) Execute(ctx context.Context, args json.RawMessage) (string, error) {
253 output, _, err := r.ExecuteRead(ctx, args)
254 return output, err
255 }
256
257 func (r readFile) scanEncoded(f io.Reader, offset, limit int) (string, error) {
258
259 // Peek the first 8 KiB to reject binary files cheaply (a NUL byte) before
260 // reading further — keeps a multi-GB archive from being slurped just to be
261 // discarded.
262 peek := make([]byte, readFileBinaryPeek)
263 pn, perr := io.ReadFull(f, peek)
264 peek = peek[:pn]
265 peekEOF := perr != nil // whole file fit in the peek (EOF / ErrUnexpectedEOF)
266
267 // BOM check first: UTF-16 files contain 0x00 for every ASCII character, so a
268 // naive NUL check would misidentify them as binary.
269 switch fileenc.DetectQuick(peek) {
270 case fileenc.UTF16LE, fileenc.UTF16BE:
271 enc := fileenc.DetectQuick(peek)
272 return r.scan(transform.NewReader(io.MultiReader(bytes.NewReader(peek), f), fileenc.Decoder(enc)), offset, limit)
273 case fileenc.UTF8BOM:
274 // Strip the 3-byte BOM; the content is valid UTF-8 and streams directly.
275 body := peek
276 if len(body) >= 3 {
277 body = body[3:]
278 }
279 return r.scan(io.MultiReader(bytes.NewReader(body), f), offset, limit)
280 }
281
282 // BOM-less UTF-16 (Windows source files) has a NUL for every ASCII char but
283 // no BOM, so it reaches here; recognise it by its NUL pattern and decode it
284 // rather than rejecting it as binary.
285 if k, ok := fileenc.DetectUTF16NoBOM(peek); ok {
286 return r.scan(transform.NewReader(io.MultiReader(bytes.NewReader(peek), f), fileenc.Decoder(k)), offset, limit)
287 }
288
289 if bytes.IndexByte(peek, 0) >= 0 {
290 return "", fmt.Errorf("binary file (NUL byte detected); use a binary inspection tool")
291 }
292
293 // Read up to a bounded sample for encoding detection, then stream the rest —
294 // so a large text file isn't slurped whole just to return a few lines.
295 head := peek
296 if !peekEOF {
297 more := make([]byte, readFileDetectSample-len(peek))
298 mn, merr := io.ReadFull(f, more)
299 head = append(peek, more[:mn]...)
300 peekEOF = merr != nil
301 }
302
303 // Detect from a char-safe slice: when more file follows, trim to the last
304 // newline so the sample never ends mid multi-byte sequence (UTF-8 and GB18030
305 // are ASCII-transparent, so '\n' is always a clean boundary).
306 sample := head
307 if !peekEOF {
308 if i := bytes.LastIndexByte(head, '\n'); i >= 0 {
309 sample = head[:i+1]
310 }
311 }
312 enc, _ := fileenc.Detect(sample)
313
314 src := io.MultiReader(bytes.NewReader(head), f)
315 if dec := fileenc.Decoder(enc); dec != nil {
316 return r.scan(transform.NewReader(src, dec), offset, limit)
317 }
318 return r.scan(src, offset, limit)
319 }
320
321 // scan reads lines from src and returns the formatted output with line numbers.
322 func (r readFile) scan(src io.Reader, offset, limit int) (string, error) {
323 scanner := bufio.NewScanner(src)
324 scanner.Buffer(make([]byte, 0, 64*1024), readFileMaxLineBytes)
325
326 var collected []string
327 textBytes := 0
328 lineNo := 0
329 hasMore := false
330 safetyPaged := false
331 requestedEnd := offset + limit
332 for scanner.Scan() {
333 lineNo++
334 if lineNo <= offset {
335 continue
336 }
337 if len(collected) < limit {
338 line := scanner.Text()
339 count := len(collected) + 1
340 width := len(strconv.Itoa(offset + count))
341 nextOffset := offset + count
342 bodyBytes := textBytes + len(line) + count*(width+len("→")+1)
343 trailer := readFileSafetyTrailer(nextOffset, requestedEnd)
344 if bodyBytes+len(trailer) > readFileMaxFormattedBytes {
345 hasMore = true
346 safetyPaged = true
347 break
348 }
349 collected = append(collected, line)
350 textBytes += len(line)
351 continue
352 }
353 // A line past the requested window exists — stop here rather than reading
354 // the rest of the file just to count the remainder.
355 hasMore = true
356 break
357 }
358 if err := scanner.Err(); err != nil {
359 if strings.Contains(err.Error(), "token too long") {
360 return "", fmt.Errorf("scan: source line exceeds the 1 MiB local safety limit: %w", err)
361 }
362 return "", fmt.Errorf("scan: %w", err)
363 }
364
365 if lineNo == 0 {
366 return readFileEmptyOutput, nil
367 }
368 if len(collected) == 0 {
369 return fmt.Sprintf("(offset %d%s%d lines)", offset, readFilePastEOFTail, lineNo), nil
370 }
371
372 maxShown := offset + len(collected)
373 w := len(fmt.Sprint(maxShown))
374
375 var b strings.Builder
376 for i, line := range collected {
377 fmt.Fprintf(&b, "%*d→%s\n", w, offset+i+1, line)
378 }
379 if safetyPaged {
380 b.WriteString(readFileSafetyTrailer(offset+len(collected), requestedEnd))
381 } else if hasMore {
382 fmt.Fprintf(&b, "\n[PARTIAL view: showing lines %d-%d of at least %d; pass offset=%d to continue. A partial window may be sufficient for local work.]\n", offset+1, maxShown, lineNo, maxShown)
383 }
384 return b.String(), nil
385 }
386
387 func readFileSafetyTrailer(nextOffset, requestedEnd int) string {
388 return fmt.Sprintf("\n[read_file local safety page; next_offset=%d requested_end=%d]\n", nextOffset, requestedEnd)
389 }
390
390 lines GO