@@ -18,26 +18,6 @@ struct LastEditAttempt: @unchecked Sendable {
1818 let oldString : String
1919}
2020
21- /// Tracks how many times a file has been read per tab. Enforces a limit that resets on edit.
22- /// Base limit: 3 reads. Each edit grants additional reads: 1st edit → 2 more, 2nd edit → 1 more.
23- /// After that, the file is blocked from further reads until another edit occurs.
24- struct FileReadCount : @unchecked Sendable {
25- var readCount : Int = 0
26- var editCount : Int = 0
27-
28- /// Maximum reads allowed given the number of edits made to this file.
29- /// 0 edits → 3 reads, 1 edit → 5 reads, 2 edits → 6 reads, N edits → 4 + N reads.
30- /// Every edit grants at least 1 additional read so the LLM can always verify its edit.
31- var maxReads : Int {
32- switch editCount {
33- case 0 : return 3
34- case 1 : return 5
35- case 2 : return 6
36- default : return 4 + editCount
37- }
38- }
39- }
40-
4121/// Snapshot of the last successful read_file emission for a (tab, path, offset, limit).
4222/// Stores mtime+size as a fast pre-filter and a sha256 of the file's raw bytes as
4323/// the authoritative "data is exactly the same" check. If all three match on a
@@ -65,98 +45,32 @@ extension AgentViewModel {
6545 return last. filePath == filePath && last. oldString == oldString
6646 }
6747
68- /// Read-count tracking per (tab, file) — keyed by "\(tabUUID):\(normalizedPath)"
69- private static var _readCounts : [ String : FileReadCount ] = [ : ]
48+ /// Single lock guarding the read-dedup cache below.
7049 private static let _readCountLock = NSLock ( )
7150
72- /// Check if a read is allowed. Increments the counter. Returns nil if allowed, or an error string if blocked.
73- static func checkAndIncrementReadCount( tabID: UUID , filePath: String ) -> String ? {
74- _readCountLock. lock ( )
75- defer { _readCountLock. unlock ( ) }
76- let key = " \( tabID. uuidString) : \( filePath) "
77- var entry = _readCounts [ key] ?? FileReadCount ( )
78- if entry. readCount >= entry. maxReads {
79- return """
80- ⛔ Read limit reached for this file ( \( entry. readCount) reads, \( entry. editCount) edits). \
81- You have read this file too many times without making progress. \
82- Recovery: edit the file first (edit_file, diff_apply, write_file), then you can read it again. \
83- If you're stuck, explain what you need and ask for help instead of re-reading.
84- """
85- }
86- entry. readCount += 1
87- let remaining = entry. maxReads - entry. readCount
88- _readCounts [ key] = entry
89- if remaining <= 1 {
90- return " ⚠️ Read limit warning: \( remaining) read(s) remaining for this file before you must edit it. ( \( entry. readCount) / \( entry. maxReads) ) "
91- }
92- return nil // allowed, no warning
93- }
94-
95- /// Reset read count for a file when it's edited — grants additional reads.
96- /// Also clears the dedup cache for this file (content is now stale) AND
97- /// resets the cross-file "reads since last edit" counter for this tab —
98- /// editing is the signal that the model is acting on what it's read.
99- /// Callers: every write/edit/apply_diff/diff_and_apply path in FileTools.swift
100- /// MUST call this after a successful write so guards reset correctly.
51+ /// Called by every successful write/edit/apply_diff/diff_and_apply/apply_patch
52+ /// path in FileTools.swift. Drops all dedup slots for this file — content has
53+ /// changed, so the model is allowed exactly one fresh read of each range again.
10154 static func recordFileEdit( tabID: UUID , filePath: String ) {
10255 _readCountLock. lock ( )
10356 defer { _readCountLock. unlock ( ) }
104- let key = " \( tabID. uuidString) : \( filePath) "
105- var entry = _readCounts [ key] ?? FileReadCount ( )
106- entry. editCount += 1
107- _readCounts [ key] = entry
108- // Drop all dedup slots for this file (all ranges) — content has changed.
10957 let dedupPrefix = " \( tabID. uuidString) : \( filePath) : "
11058 _lastReadEmissions = _lastReadEmissions. filter { !$0. key. hasPrefix ( dedupPrefix) }
111- _readsSinceEditByTab [ tabID. uuidString] = 0
11259 }
11360
114- /// Clear read counts for a tab (called when the tab's conversation resets).
61+ /// Clear all read-dedup state for a tab (called when the tab's conversation resets).
11562 static func clearReadCountsForTab( tabID: UUID ) {
11663 _readCountLock. lock ( )
11764 defer { _readCountLock. unlock ( ) }
11865 let prefix = tabID. uuidString + " : "
119- _readCounts = _readCounts. filter { !$0. key. hasPrefix ( prefix) }
12066 _lastReadEmissions = _lastReadEmissions. filter { !$0. key. hasPrefix ( prefix) }
121- _readsSinceEditByTab. removeValue ( forKey: tabID. uuidString)
122- }
123-
124- /// Cross-file counter: how many distinct content-bearing reads have happened
125- /// in this tab since the last edit. Per-file counter catches "3 reads of the
126- /// same file"; this catches "read 7 different files without acting on any."
127- /// Reset on any edit (recordFileEdit) and on tab reset.
128- private static var _readsSinceEditByTab : [ String : Int ] = [ : ]
129-
130- /// Threshold for the cross-file read-without-edit guard. Tuned to allow
131- /// genuine orientation (read ~5 files to understand the area) but stop the
132- /// "read everything then read it again" spiral.
133- private static let readsWithoutEditThreshold = 6
134-
135- /// Increment the cross-file counter and return a hard-stop message if the
136- /// threshold is exceeded. Call AFTER the dedup check — dedup hits don't
137- /// count (they emit no new content).
138- static func checkConsecutiveReadsWithoutEdit( tabID: UUID ) -> String ? {
139- _readCountLock. lock ( )
140- defer { _readCountLock. unlock ( ) }
141- let key = tabID. uuidString
142- let next = ( _readsSinceEditByTab [ key] ?? 0 ) + 1
143- _readsSinceEditByTab [ key] = next
144- guard next > readsWithoutEditThreshold else { return nil }
145- return """
146- 🛑 STOP — \( next) file reads in a row without a single edit. \
147- Continued reading is forbidden until you ACT. \
148- Recovery: pick the single most likely file and call edit_file, \
149- write_file, apply_diff, or diff_and_apply NOW — or call \
150- task_complete and honestly report what is still unknown. \
151- Another read_file, list_files, or search_files call without an \
152- edit in between is a contract violation.
153- """
15467 }
15568
15669 /// Dedup cache keyed by "\(tabUUID):\(expandedPath):\(offset):\(limit)". Each
15770 /// distinct range gets its own slot — partial reads of DIFFERENT line ranges
158- /// are allowed (lines 1–10 then lines 2–20 is fine), but the SAME range
159- /// repeated on an unchanged file is blocked.
71+ /// are allowed (lines 1–10 then lines 2–20 is fine, each can be read once),
72+ /// but the SAME range repeated on an unchanged file is blocked. The rule:
73+ /// ONE read per (file, range) while the data is unchanged.
16074 static var _lastReadEmissions : [ String : LastReadEmission ] = [ : ]
16175
16276 static func dedupKey( tabID: UUID , expandedPath: String , offset: Int ? , limit: Int ? ) -> String {
@@ -201,22 +115,20 @@ extension AgentViewModel {
201115 else { return nil }
202116 let rangeDesc : String
203117 if offset != nil || limit != nil {
204- rangeDesc = " offset= \( offset. map ( String . init) ?? " nil " ) limit= \( limit. map ( String . init) ?? " nil " ) "
118+ rangeDesc = " lines offset=\( offset. map ( String . init) ?? " nil " ) limit= \( limit. map ( String . init) ?? " nil " ) "
205119 } else {
206120 rangeDesc = " the entire file "
207121 }
208122 let hashPrefix = String ( currentHash. prefix ( 12 ) )
209123 return """
210- 🛑 BLOCKED: re-read of unchanged data.
211- You already read \( expandedPath) in this conversation for the same range \
212- ( \( rangeDesc) ), and the file's content is byte-for-byte identical \
213- (sha256 \( hashPrefix) … matches the prior emission).
214- Reading the same data twice wastes tokens and teaches you nothing. \
215- Recovery:
216- • Act on what you have: call edit_file / write_file / apply_diff / diff_and_apply now.
217- • OR request a DIFFERENT line range (different offset/limit) if you genuinely need other parts of the file.
218- • OR call task_complete if you've finished.
219- This block is automatic and clears the instant the file is edited.
124+ 🛑 BLOCKED. You can only read each file (or each section of a file) 1 time \
125+ unless the data has changed. You already read \( expandedPath) for this same \
126+ range ( \( rangeDesc) ) and the data is byte-for-byte identical \
127+ (sha256 \( hashPrefix) …). The data is stale — no read is allowed.
128+ Allowed next moves:
129+ • Read a DIFFERENT range of this file (different offset/limit) — each unique range gets 1 read.
130+ • Edit the file (edit_file / write_file / apply_diff / diff_and_apply) — that changes the data and allows another read.
131+ • Act on what you already have, or call task_complete.
220132 """
221133 }
222134
0 commit comments