| 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 |