Skip to content

Commit 4a373bb

Browse files
committed
Strip read guards down to the one rule: 1 read per (file, range)
The per-file counter (max 3 reads) and cross-file counter (max 6 reads without edit) contradicted the actual rule. They allowed up to 3 re-reads of the same range and capped total ranges per file. The rule: - Each unique (file, range) → exactly 1 read while data is unchanged. - N different ranges of the same unchanged file → N reads (one each). - File changes → all slots reset, each range can be read once again. sha256 dedup already enforces this perfectly. Removed: - FileReadCount struct + _readCounts dict - checkAndIncrementReadCount - _readsSinceEditByTab dict + checkConsecutiveReadsWithoutEdit - readsWithoutEditThreshold constant - The two extra guard calls in FileTools.swift read_file case Block message updated to use the user's language: "you can only read each file (or each section) 1 time unless the data has changed … data is stale — no read is allowed."
1 parent 02916a3 commit 4a373bb

2 files changed

Lines changed: 23 additions & 123 deletions

File tree

Agent/AgentViewModel/NativeToolHandlers/File.swift

Lines changed: 17 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Agent/AgentViewModel/TaskExecution/FileTools.swift

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -48,30 +48,18 @@ extension AgentViewModel {
4848
let expanded = (filePath as NSString).expandingTildeInPath
4949
let tabID = selectedTabId ?? Self.mainTabID
5050

51-
// Guards (canonical, single source of truth — helpers live on the
52-
// AgentViewModel extension in NativeToolHandlers/File.swift):
53-
// (1) Dedup with sha256: same (path, offset, limit) + unchanged
54-
// content → BLOCK with explicit rule + hash citation.
51+
// ONE read per (file, range) while the data is unchanged.
52+
// sha256 of the raw file bytes is the authoritative check; the rule
53+
// and the helper live on the AgentViewModel extension in
54+
// NativeToolHandlers/File.swift. Different ranges get separate slots
55+
// — lines 1-10 then 2-20 is allowed (each once). Same range repeated
56+
// on unchanged data is blocked. Editing the file clears the slots.
5557
if let dedup = Self.dedupRead(tabID: tabID, expandedPath: expanded, offset: offset, limit: limit) {
5658
appendLog("📖 Read: \(filePath)")
5759
appendLog(dedup)
5860
toolResults.append(["type": "tool_result", "tool_use_id": toolId, "content": dedup])
5961
return true
6062
}
61-
// (2) Cross-file loop guard: N content-bearing reads with zero edits.
62-
if let stop = Self.checkConsecutiveReadsWithoutEdit(tabID: tabID) {
63-
appendLog("📖 Read: \(filePath)")
64-
appendLog(stop)
65-
toolResults.append(["type": "tool_result", "tool_use_id": toolId, "content": stop])
66-
return true
67-
}
68-
// (3) Per-file counter: 3 reads of the same file before requiring an edit.
69-
if let blocked = Self.checkAndIncrementReadCount(tabID: tabID, filePath: expanded) {
70-
appendLog("📖 Read: \(filePath)")
71-
appendLog(blocked)
72-
toolResults.append(["type": "tool_result", "tool_use_id": toolId, "content": blocked])
73-
return true
74-
}
7563

7664
appendLog("📖 Read: \(filePath)")
7765
let output = await Self.offMain { CodingService.readFile(path: filePath, offset: offset, limit: limit) }

0 commit comments

Comments
 (0)