| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "path/filepath" |
| 8 | "strconv" |
| 9 | "sync" |
| 10 | |
| 11 | "reasonix/internal/tool" |
| 12 | ) |
| 13 | |
| 14 | // readState groups the run-scoped read registry with its generation. |
| 15 | type readState struct { |
| 16 | tasks *readTasks |
| 17 | runGen uint64 |
| 18 | // deliveries retains metadata only; visible is rebuilt from each frozen request. |
| 19 | deliveries map[string]readDelivery |
| 20 | visible map[string]readDelivery |
| 21 | } |
| 22 | |
| 23 | // readTasks keeps the logical identity of in-flight read tasks so a |
| 24 | // continuation page joins the read it continues. A forged, expired, |
| 25 | // cross-session, cross-file, or out-of-position cursor is rejected at the |
| 26 | // execution entry; decoding a token is never the same as accepting it. |
| 27 | type readTasks struct { |
| 28 | mu sync.Mutex |
| 29 | sessionID string |
| 30 | generation uint64 |
| 31 | binding string |
| 32 | byID map[string]readTask |
| 33 | } |
| 34 | |
| 35 | type readTask struct { |
| 36 | path string |
| 37 | argumentPath string |
| 38 | snapshot string |
| 39 | requestEnd int |
| 40 | cursor tool.ReadCursor |
| 41 | issued bool |
| 42 | } |
| 43 | |
| 44 | func newReadTasks(sessionID string, generation uint64) *readTasks { |
| 45 | return &readTasks{sessionID: sessionID, generation: generation, binding: rand.Text(), byID: map[string]readTask{}} |
| 46 | } |
| 47 | |
| 48 | // accept reports whether the cursor may continue a live read task. |
| 49 | func (r *readTasks) accept(cursor tool.ReadCursor, path string) bool { |
| 50 | if r == nil { |
| 51 | return false |
| 52 | } |
| 53 | r.mu.Lock() |
| 54 | defer r.mu.Unlock() |
| 55 | |
| 56 | task, known := r.byID[cursor.ReadID] |
| 57 | switch { |
| 58 | case !known: |
| 59 | return false |
| 60 | case cursor.Binding != r.binding: |
| 61 | return false |
| 62 | case cursor.SessionID != r.sessionID: |
| 63 | return false |
| 64 | case cursor.RunGen != r.generation: |
| 65 | return false |
| 66 | case cursor.Path != path || cursor.Path != task.path: |
| 67 | return false |
| 68 | case cursor.Snapshot == "" || cursor.Snapshot != task.snapshot: |
| 69 | return false |
| 70 | case !task.issued || cursor != task.cursor: |
| 71 | return false |
| 72 | } |
| 73 | return true |
| 74 | } |
| 75 | |
| 76 | // remember records the task's latest snapshot and requested window. |
| 77 | func (r *readTasks) remember(readID string, env tool.ReadResultEnvelope, paths ...string) { |
| 78 | if r == nil || readID == "" { |
| 79 | return |
| 80 | } |
| 81 | requestEnd := 0 |
| 82 | if env.RequestedRange != nil { |
| 83 | requestEnd = env.RequestedRange.End |
| 84 | } |
| 85 | r.mu.Lock() |
| 86 | defer r.mu.Unlock() |
| 87 | cursor, issued := tool.DecodeReadCursor(env.NextCursor) |
| 88 | argumentPath := r.byID[readID].argumentPath |
| 89 | if len(paths) > 0 && paths[0] != "" { |
| 90 | argumentPath = paths[0] |
| 91 | } |
| 92 | r.byID[readID] = readTask{path: env.Source.CanonicalPath, argumentPath: argumentPath, snapshot: env.Source.Snapshot, requestEnd: requestEnd, cursor: cursor, issued: issued} |
| 93 | } |
| 94 | |
| 95 | // resolveReadCursor rewrites a continuation call into the explicit window its |
| 96 | // cursor names and marks the plan with the logical read it continues. A cursor |
| 97 | // the host cannot vouch for is an error, never a silent new read. |
| 98 | func (a *Agent) resolveReadCursor(plan *toolCallPlan) (toolOutcome, bool) { |
| 99 | token := readCursorArg(plan.execArgs) |
| 100 | if token == "" { |
| 101 | return toolOutcome{}, false |
| 102 | } |
| 103 | cursor, ok := tool.DecodeReadCursor(token) |
| 104 | if !ok { |
| 105 | return readCursorRejected("the read continuation cursor is malformed; re-read the file with read_file", plan) |
| 106 | } |
| 107 | path := readPathArg(plan.execArgs) |
| 108 | if resolver, ok := plan.execTool.(tool.ReadPathResolver); ok { |
| 109 | resolved, err := resolver.ResolveReadPath(plan.execArgs) |
| 110 | if err != nil { |
| 111 | return readCursorRejected(err.Error(), plan) |
| 112 | } |
| 113 | path = resolved |
| 114 | } |
| 115 | if !filepath.IsAbs(path) && a.writeWorkspaceRoot != "" { |
| 116 | path = filepath.Join(a.writeWorkspaceRoot, path) |
| 117 | } |
| 118 | path = filepath.Clean(path) |
| 119 | if !a.reads.tasks.accept(cursor, path) { |
| 120 | return readCursorRejected("the read continuation cursor is not valid for this file or session; re-read the file with read_file", plan) |
| 121 | } |
| 122 | rewritten, err := withResolvedReadWindow(plan.execArgs, cursor) |
| 123 | if err != nil { |
| 124 | return readCursorRejected(err.Error(), plan) |
| 125 | } |
| 126 | plan.execArgs = rewritten |
| 127 | plan.permArgs = rewritten |
| 128 | plan.evidenceArgs = rewritten |
| 129 | plan.readTaskID = cursor.ReadID |
| 130 | plan.readSnapshot = cursor.Snapshot |
| 131 | return toolOutcome{}, false |
| 132 | } |
| 133 | |
| 134 | func readCursorRejected(msg string, plan *toolCallPlan) (toolOutcome, bool) { |
| 135 | d := &tool.OperationDiagnostic{Code: tool.ReadCursorInvalid, OperationID: plan.call.ID, Path: readPathArg(plan.execArgs), Recovery: "inspect a fresh explicit range; do not reuse the rejected cursor"} |
| 136 | return toolOutcome{output: "error: " + msg, errMsg: msg, blocked: true, diagnostic: d}, true |
| 137 | } |
| 138 | |
| 139 | func readCursorArg(args json.RawMessage) string { |
| 140 | var fields struct { |
| 141 | Cursor string `json:"cursor"` |
| 142 | } |
| 143 | if err := json.Unmarshal(args, &fields); err != nil { |
| 144 | return "" |
| 145 | } |
| 146 | return fields.Cursor |
| 147 | } |
| 148 | |
| 149 | func readPathArg(args json.RawMessage) string { |
| 150 | var fields struct { |
| 151 | Path string `json:"path"` |
| 152 | } |
| 153 | if err := json.Unmarshal(args, &fields); err != nil { |
| 154 | return "" |
| 155 | } |
| 156 | return fields.Path |
| 157 | } |
| 158 | |
| 159 | // withResolvedReadWindow replaces the cursor with the explicit offset/limit it |
| 160 | // names, so the reader never needs to understand the token. |
| 161 | func withResolvedReadWindow(args json.RawMessage, cursor tool.ReadCursor) (json.RawMessage, error) { |
| 162 | var fields map[string]json.RawMessage |
| 163 | if err := json.Unmarshal(args, &fields); err != nil { |
| 164 | return nil, fmt.Errorf("invalid args: %w", err) |
| 165 | } |
| 166 | if _, present := fields["offset"]; present { |
| 167 | return nil, fmt.Errorf("cursor cannot be combined with offset; pass the issued cursor unchanged") |
| 168 | } |
| 169 | if _, present := fields["limit"]; present { |
| 170 | return nil, fmt.Errorf("cursor cannot be combined with limit; pass the issued cursor unchanged") |
| 171 | } |
| 172 | delete(fields, "cursor") |
| 173 | delete(fields, "intent") |
| 174 | delete(fields, "offset") |
| 175 | delete(fields, "limit") |
| 176 | fields["offset"] = json.RawMessage(strconv.Itoa(cursor.NextStart)) |
| 177 | if cursor.RequestEnd > cursor.NextStart { |
| 178 | fields["limit"] = json.RawMessage(strconv.Itoa(cursor.RequestEnd - cursor.NextStart)) |
| 179 | } |
| 180 | out, err := json.Marshal(fields) |
| 181 | if err != nil { |
| 182 | return nil, fmt.Errorf("invalid args: %w", err) |
| 183 | } |
| 184 | return out, nil |
| 185 | } |
| 186 |