返回 DeepSeek-Reasonix
readresult.go
根目录 / internal / tool / readresult.go
1 package tool
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/base64"
7 "encoding/hex"
8 "encoding/json"
9 "strconv"
10 "strings"
11 )
12
13 // ReadResultProtocolVersion is the host-only read-result contract. Additive
14 // fields keep the version; changing an existing field's meaning bumps it.
15 // Version 1 envelopes are diagnostic only and never authorize a write.
16 const ReadResultProtocolVersion = 2
17
18 // ReadIntent records why a read happened. It is decided by the host from the
19 // call's arguments, never inferred from free text.
20 type ReadIntent string
21
22 const (
23 // ReadIntentInspect is a bounded preview: completing one page completes the
24 // obligation, and remaining content is not an outstanding read debt.
25 ReadIntentInspect ReadIntent = "inspect"
26 // ReadIntentRange is an explicit window: it completes at the window's end
27 // or at a trustworthy source end.
28 ReadIntentRange ReadIntent = "range"
29 // ReadIntentFull promises whole-file coverage on one content version.
30 ReadIntentFull ReadIntent = "full"
31 )
32
33 // ReadRange is a half-open interval [Start, End) over zero-based line indices,
34 // matching read_file's offset argument. Line N (1-based) is index N-1.
35 type ReadRange struct {
36 Start int `json:"start"`
37 End int `json:"end"`
38 }
39
40 // Empty reports whether the range covers no lines.
41 func (r ReadRange) Empty() bool { return r.End <= r.Start }
42
43 // Lines returns the number of lines covered.
44 func (r ReadRange) Lines() int {
45 if r.Empty() {
46 return 0
47 }
48 return r.End - r.Start
49 }
50
51 // ReadCutReason names why a delivery stopped short of the source's end.
52 type ReadCutReason string
53
54 const (
55 ReadCutNone ReadCutReason = ""
56 ReadCutPageLimit ReadCutReason = "page_limit" // requested line limit reached
57 ReadCutSafetyPage ReadCutReason = "safety_page" // local formatted-byte safety page
58 ReadCutToolOutput ReadCutReason = "tool_output" // provider-visible byte budget
59 )
60
61 // ReadSourceKind names the store a read actually served.
62 type ReadSourceKind string
63
64 const (
65 ReadSourceDisk ReadSourceKind = "disk"
66 ReadSourceOverlay ReadSourceKind = "overlay"
67 )
68
69 // ReadResultSource identifies where the delivered bytes came from and which
70 // content version they belong to.
71 type ReadResultSource struct {
72 WorkspaceID string `json:"workspace_id,omitempty"`
73 CanonicalPath string `json:"canonical_path"`
74 Kind ReadSourceKind `json:"kind,omitempty"`
75 // Identity binds captured disk bytes or the exact serving overlay buffer.
76 // Empty means the bounded reader did not capture a versionable source.
77 Identity string `json:"identity,omitempty"`
78 // Snapshot is the content version of this logical read. It stays constant
79 // across the pages of one read and changes when the source content changes.
80 // An unversioned partial read cannot be stitched into whole-file evidence.
81 Snapshot string `json:"snapshot,omitempty"`
82 }
83
84 // ReadResultEnvelope is host-only metadata describing what a reader actually
85 // delivered to the model. It never enters a provider request, and the model
86 // sees only the result text and its line numbers.
87 type ReadResultEnvelope struct {
88 ProtocolVersion int `json:"protocol_version"`
89 ReadID string `json:"read_id,omitempty"`
90 ResultRef string `json:"result_ref,omitempty"`
91 Source ReadResultSource `json:"source"`
92 Intent ReadIntent `json:"intent"`
93 // RequestedRange is nil when the caller requested no explicit window.
94 RequestedRange *ReadRange `json:"requested_range,omitempty"`
95 DeliveredRanges []ReadRange `json:"delivered_ranges,omitempty"`
96 // WindowDigest covers exactly the delivered lines of this page. It proves
97 // this window and can never stand in for a whole-file version.
98 WindowDigest string `json:"window_digest,omitempty"`
99 HasMore bool `json:"has_more"`
100 // EOF reports that this delivery reached the source's end.
101 EOF bool `json:"eof"`
102 // SourceEnd is the zero-based end line index of the source when the reader
103 // established it (EOF reached, or the file is empty). nil means the reader
104 // stopped early and cannot vouch for where the source ends.
105 SourceEnd *int `json:"source_end,omitempty"`
106 NextCursor string `json:"next_cursor,omitempty"`
107 SourceCut ReadCutReason `json:"source_cut_reason,omitempty"`
108 // TransportCut names a provider-visible truncation on top of the source cut.
109 TransportCut ReadCutReason `json:"transport_cut_reason,omitempty"`
110 }
111
112 // ReadExecutor returns metadata from the same immutable source as the output.
113 // Consumers must not reconstruct source identity by probing the file later.
114 type ReadExecutor interface {
115 ExecuteRead(context.Context, json.RawMessage) (string, ReadResultEnvelope, error)
116 }
117
118 // ReadPathResolver uses the reader's own workspace/alias routing.
119 type ReadPathResolver interface {
120 ResolveReadPath(json.RawMessage) (string, error)
121 }
122
123 type fullReadSnapshotKey struct{}
124
125 // WithFullReadSnapshot is host-only intent for a full task's continuation.
126 func WithFullReadSnapshot(ctx context.Context) context.Context {
127 return context.WithValue(ctx, fullReadSnapshotKey{}, true)
128 }
129 func FullReadSnapshotRequested(ctx context.Context) bool {
130 requested, _ := ctx.Value(fullReadSnapshotKey{}).(bool)
131 return requested
132 }
133
134 // ReadWindow is the contiguous numbered window a reader rendered.
135 type ReadWindow struct {
136 StartLine int
137 Lines []string
138 }
139
140 // Range returns the zero-based half-open interval the window covers.
141 func (w ReadWindow) Range() ReadRange {
142 return ReadRange{Start: w.StartLine - 1, End: w.StartLine - 1 + len(w.Lines)}
143 }
144
145 // ParseReadWindow extracts the contiguous ` 42→text` window from a reader's
146 // output. Non-contiguous or unnumbered output returns ok=false: callers must
147 // fail closed rather than stitch unrelated windows into one observation.
148 func ParseReadWindow(output string) (ReadWindow, bool) {
149 var w ReadWindow
150 for line := range strings.SplitSeq(output, "\n") {
151 arrow := strings.Index(line, "→")
152 if arrow <= 0 {
153 continue
154 }
155 lineNo, err := strconv.Atoi(strings.TrimSpace(line[:arrow]))
156 if err != nil || lineNo < 1 {
157 continue
158 }
159 if len(w.Lines) == 0 {
160 w.StartLine = lineNo
161 } else if lineNo != w.StartLine+len(w.Lines) {
162 return ReadWindow{}, false
163 }
164 w.Lines = append(w.Lines, line[arrow+len("→"):])
165 }
166 if len(w.Lines) == 0 {
167 return ReadWindow{}, false
168 }
169 return w, true
170 }
171
172 // ReadTrailer is the paging state a reader appends to its own result text. The
173 // zero value means no trailer was present.
174 type ReadTrailer struct {
175 NextOffset int
176 RequestedEnd int
177 HasMore bool
178 LocalSafety bool
179 }
180
181 // ParseReadTrailer reads the reader's own paging trailer. It is the reader's
182 // format, not a third party's, so the reader owns both sides of it.
183 func ParseReadTrailer(output string) ReadTrailer {
184 const safetyPrefix = "\n[read_file local safety page; next_offset="
185 if start := strings.LastIndex(output, safetyPrefix); start >= 0 && strings.HasSuffix(output, "]\n") {
186 fields := strings.TrimSuffix(output[start+len(safetyPrefix):], "]\n")
187 parts := strings.Fields(fields)
188 if len(parts) == 2 {
189 next, nextErr := strconv.Atoi(parts[0])
190 end, endErr := strconv.Atoi(strings.TrimPrefix(parts[1], "requested_end="))
191 if nextErr == nil && endErr == nil && next >= 0 && end >= next {
192 return ReadTrailer{NextOffset: next, RequestedEnd: end, HasMore: true, LocalSafety: true}
193 }
194 }
195 }
196 const prefix = "\n[more lines below; pass offset="
197 start := strings.LastIndex(output, prefix)
198 valueStart := start + len(prefix)
199 if partial := strings.LastIndex(output, "\n[PARTIAL view:"); partial >= 0 {
200 if field := strings.Index(output[partial:], "pass offset="); field >= 0 {
201 start, valueStart = partial, partial+field+len("pass offset=")
202 }
203 }
204 if start < 0 || !strings.HasSuffix(output, "]\n") {
205 return ReadTrailer{}
206 }
207 value := output[valueStart:]
208 if end := strings.IndexAny(value, " ]\r\n"); end >= 0 {
209 value = value[:end]
210 }
211 n, err := strconv.Atoi(value)
212 if err != nil || n < 0 {
213 return ReadTrailer{}
214 }
215 return ReadTrailer{NextOffset: n, HasMore: true}
216 }
217
218 // WindowDigest binds one delivered window to its content. Two reads that
219 // deliver byte-identical lines produce the same digest; any edit inside the
220 // window changes it. It says nothing about lines outside the window.
221 func WindowDigest(canonicalPath string, w ReadWindow) string {
222 h := sha256.New()
223 h.Write([]byte("reasonix/read-window/v2\x00"))
224 h.Write([]byte(canonicalPath))
225 h.Write([]byte{0})
226 h.Write([]byte(strconv.Itoa(w.StartLine)))
227 for _, line := range w.Lines {
228 h.Write([]byte{0})
229 h.Write([]byte(line))
230 }
231 return "wd2:" + hex.EncodeToString(h.Sum(nil))
232 }
233
234 // SourceSnapshot derives the content version of one logical read from its
235 // source kind and store identity. The same source yields the same snapshot
236 // across pages; a changed store yields a different one.
237 func SourceSnapshot(kind ReadSourceKind, canonicalPath, identity string) string {
238 if identity == "" {
239 return ""
240 }
241 h := sha256.New()
242 h.Write([]byte("reasonix/read-source/v2\x00"))
243 h.Write([]byte(kind))
244 h.Write([]byte{0})
245 h.Write([]byte(canonicalPath))
246 h.Write([]byte{0})
247 h.Write([]byte(identity))
248 return "ss2:" + hex.EncodeToString(h.Sum(nil))
249 }
250
251 // ClipTo narrows the envelope to the numbered lines actually present in the
252 // provider-visible text, recording the transport cut. Callers pass the raw
253 // result unchanged when nothing was truncated.
254 func (e ReadResultEnvelope) ClipTo(visible string) ReadResultEnvelope {
255 w, ok := ParseReadWindow(visible)
256 if !ok {
257 e.DeliveredRanges = nil
258 e.WindowDigest = ""
259 e.HasMore = true
260 e.EOF = false
261 e.SourceEnd = nil
262 e.TransportCut = ReadCutToolOutput
263 return e
264 }
265 visibleRange := w.Range()
266 covered := len(e.DeliveredRanges) > 0
267 for _, r := range e.DeliveredRanges {
268 if r.Start < visibleRange.Start || r.End > visibleRange.End {
269 covered = false
270 break
271 }
272 }
273 if covered {
274 return e
275 }
276 var kept []ReadRange
277 for _, r := range e.DeliveredRanges {
278 if start, end := max(r.Start, visibleRange.Start), min(r.End, visibleRange.End); start < end {
279 kept = append(kept, ReadRange{Start: start, End: end})
280 }
281 }
282 e.DeliveredRanges = kept
283 e.WindowDigest = WindowDigest(e.Source.CanonicalPath, w)
284 e.HasMore = true
285 e.EOF = false
286 e.SourceEnd = nil
287 e.TransportCut = ReadCutToolOutput
288 e.NextCursor = EncodeReadCursor(ReadCursor{
289 Path: e.Source.CanonicalPath,
290 Snapshot: e.Source.Snapshot,
291 ReadID: e.ReadID,
292 NextStart: visibleRange.End,
293 })
294 return e
295 }
296
297 // ReadCursor is a host-issued continuation reference. It is opaque to callers,
298 // bound to one session, run generation, read task, source snapshot, requested
299 // window, and exact next position, and is validated at the execution entry —
300 // decoding it is not the same as accepting it.
301 type ReadCursor struct {
302 Version int `json:"v"`
303 Binding string `json:"b,omitempty"`
304 SessionID string `json:"s,omitempty"`
305 RunGen uint64 `json:"g,omitempty"`
306 ReadID string `json:"r"`
307 Path string `json:"p"`
308 Snapshot string `json:"n,omitempty"`
309 RequestEnd int `json:"e,omitempty"`
310 NextStart int `json:"i"`
311 }
312
313 const readCursorPrefix = "rc2:"
314
315 // EncodeReadCursor renders a cursor as an opaque token.
316 func EncodeReadCursor(c ReadCursor) string {
317 // A reader can encode the position it knows; the host stamps the logical
318 // read id before the cursor is ever handed to a model.
319 if c.Path == "" || c.NextStart < 0 {
320 return ""
321 }
322 c.Version = ReadResultProtocolVersion
323 raw, err := json.Marshal(c)
324 if err != nil {
325 return ""
326 }
327 return readCursorPrefix + base64.RawURLEncoding.EncodeToString(raw)
328 }
329
330 // DecodeReadCursor parses a token produced by EncodeReadCursor. It only proves
331 // the token is well formed; callers must still validate it against the live
332 // session, run, read task, and source snapshot.
333 func DecodeReadCursor(token string) (ReadCursor, bool) {
334 rest, ok := strings.CutPrefix(token, readCursorPrefix)
335 if !ok {
336 return ReadCursor{}, false
337 }
338 raw, err := base64.RawURLEncoding.DecodeString(rest)
339 if err != nil {
340 return ReadCursor{}, false
341 }
342 var c ReadCursor
343 if err := json.Unmarshal(raw, &c); err != nil {
344 return ReadCursor{}, false
345 }
346 if c.Version != ReadResultProtocolVersion || c.Path == "" || c.NextStart < 0 {
347 return ReadCursor{}, false
348 }
349 return c, true
350 }
351
352 // Matches reports whether the cursor still belongs to this envelope: same read
353 // task, canonical path, content snapshot, and a start inside the delivered range.
354 func (c ReadCursor) Matches(e ReadResultEnvelope) bool {
355 if c.ReadID != e.ReadID || c.Path != e.Source.CanonicalPath {
356 return false
357 }
358 if c.Snapshot != "" && e.Source.Snapshot != "" && c.Snapshot != e.Source.Snapshot {
359 return false
360 }
361 if len(e.DeliveredRanges) == 0 {
362 return false
363 }
364 last := e.DeliveredRanges[len(e.DeliveredRanges)-1]
365 return c.NextStart >= last.Start && c.NextStart <= last.End
366 }
367
368 // ReadEnvelopeProvider is an optional reader capability that reports what it
369 // delivered. output is the reader's own result text; the host clips the
370 // returned envelope to the provider-visible bytes before using it.
371 type ReadEnvelopeProvider interface {
372 ReadEnvelope(ctx context.Context, args json.RawMessage, output string) (ReadResultEnvelope, bool)
373 }
374
374 lines GO