Skip to content

Commit fc482ca

Browse files
committed
feat(fs_write): fuzzy str_replace with 3-strategy fallback chain + file freshness check
The current str_replace implementation uses exact byte matching only. When the model's old_str has minor differences from the file (indentation drift, whitespace, or small context edits), the match fails and the model either retries wastefully or falls back to destructive shell commands. Implement str_replace_fuzzy() with a 3-strategy fallback chain inspired by opencode and cline's diff-apply approaches: 1. Exact match — unchanged behaviour for the common case 2. Line-trimmed match — compares lines after trim(), then replaces using byte offsets (prefix-sum table) into the original content. Handles indentation drift (tab vs spaces, different indent levels). 3. Block-anchor match — uses first+last line as anchors, scores middle lines with Levenshtein similarity, picks the best candidate above a 0.6 threshold. Handles minor edits in surrounding context lines. Also adds file freshness checking: - fs_read now records the file mtime into FileLineTracker.last_read_mtime whenever it reads a file - fs_write str_replace checks the current mtime against the recorded value before writing; if the file was modified externally it returns a clear error asking the model to re-read before retrying Also: - validate() rejects empty old_str before reaching fuzzy matching - tool_index.json description updated to reflect fuzzy tolerance and reinforce read-before-write / no-sed-fallback guidance Key correctness properties: - Strategies 2 and 3 return byte ranges — replacement is always at the correct position even if matched text appears elsewhere in the file - block_anchor_match skips first==last anchors (false positive guard) - similarity_score respects actual content window bounds - levenshtein uses O(n) rolling-row space, char count for denominator - build_line_offsets prefix-sum gives O(1) offset lookup - strip_empty_boundary_lines handles both leading and trailing empty lines 11 tests cover all strategies, edge cases, and error messages.
1 parent e14ea18 commit fc482ca

File tree

5 files changed

+464
-88
lines changed

5 files changed

+464
-88
lines changed

crates/chat-cli/src/cli/chat/line_tracker.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::time::SystemTime;
2+
13
use serde::{
24
Deserialize,
35
Serialize,
@@ -19,6 +21,10 @@ pub struct FileLineTracker {
1921
pub lines_removed_by_agent: usize,
2022
/// Whether or not this is the first `fs_write` invocation
2123
pub is_first_write: bool,
24+
/// mtime of the file at the time it was last read by `fs_read`.
25+
/// Used by `fs_write` to detect external modifications between read and write.
26+
#[serde(skip)]
27+
pub last_read_mtime: Option<SystemTime>,
2228
}
2329

2430
impl Default for FileLineTracker {
@@ -30,6 +36,7 @@ impl Default for FileLineTracker {
3036
lines_added_by_agent: 0,
3137
lines_removed_by_agent: 0,
3238
is_first_write: true,
39+
last_read_mtime: None,
3340
}
3441
}
3542
}

crates/chat-cli/src/cli/chat/tools/fs_read.rs

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::collections::HashMap;
12
use std::collections::VecDeque;
23
use std::fs::Metadata;
34
use std::io::Write;
@@ -43,6 +44,7 @@ use crate::cli::chat::{
4344
CONTINUATION_LINE,
4445
sanitize_unicode_tags,
4546
};
47+
use crate::cli::chat::line_tracker::FileLineTracker;
4648
use crate::os::Os;
4749
use crate::theme::StyledText;
4850
use crate::util::paths;
@@ -266,7 +268,25 @@ impl FsRead {
266268
}
267269
}
268270

269-
pub async fn invoke(&self, os: &Os, updates: &mut impl Write) -> Result<InvokeOutput> {
271+
pub async fn invoke(
272+
&self,
273+
os: &Os,
274+
updates: &mut impl Write,
275+
line_tracker: &mut HashMap<String, FileLineTracker>,
276+
) -> Result<InvokeOutput> {
277+
// Record mtime for each file-read operation so fs_write can detect
278+
// external modifications between read and write.
279+
for op in &self.operations {
280+
if let Some(path) = op.file_path(os) {
281+
if let Ok(meta) = std::fs::metadata(&path) {
282+
if let Ok(mtime) = meta.modified() {
283+
let key = path.to_string_lossy().to_string();
284+
line_tracker.entry(key).or_default().last_read_mtime = Some(mtime);
285+
}
286+
}
287+
}
288+
}
289+
270290
if self.operations.len() == 1 {
271291
// Single operation - return result directly
272292
self.operations[0].invoke(os, updates).await
@@ -358,6 +378,15 @@ impl FsRead {
358378
}
359379

360380
impl FsReadOperation {
381+
/// Returns the resolved file path for Line operations (the only type that reads file content).
382+
/// Used to record mtime for freshness checking in fs_write.
383+
pub fn file_path(&self, os: &Os) -> Option<std::path::PathBuf> {
384+
match self {
385+
FsReadOperation::Line(fs_line) => Some(sanitize_path_tool_arg(os, &fs_line.path)),
386+
_ => None,
387+
}
388+
}
389+
361390
pub async fn validate(&mut self, os: &Os) -> Result<()> {
362391
match self {
363392
FsReadOperation::Line(fs_line) => fs_line.validate(os).await,
@@ -943,7 +972,7 @@ mod tests {
943972
});
944973
let output = serde_json::from_value::<FsRead>(v)
945974
.unwrap()
946-
.invoke(&os, &mut stdout)
975+
.invoke(&os, &mut stdout, &mut HashMap::new())
947976
.await
948977
.unwrap();
949978

@@ -977,7 +1006,7 @@ mod tests {
9771006
assert!(
9781007
serde_json::from_value::<FsRead>(v)
9791008
.unwrap()
980-
.invoke(&os, &mut stdout)
1009+
.invoke(&os, &mut stdout, &mut HashMap::new())
9811010
.await
9821011
.is_err()
9831012
);
@@ -1010,7 +1039,7 @@ mod tests {
10101039
}]});
10111040
let output = serde_json::from_value::<FsRead>(v)
10121041
.unwrap()
1013-
.invoke(&os, &mut stdout)
1042+
.invoke(&os, &mut stdout, &mut HashMap::new())
10141043
.await
10151044
.unwrap();
10161045

@@ -1029,7 +1058,7 @@ mod tests {
10291058
});
10301059
let output = serde_json::from_value::<FsRead>(v)
10311060
.unwrap()
1032-
.invoke(&os, &mut stdout)
1061+
.invoke(&os, &mut stdout, &mut HashMap::new())
10331062
.await
10341063
.unwrap();
10351064

@@ -1055,7 +1084,7 @@ mod tests {
10551084
let v = serde_json::json!($value);
10561085
let output = serde_json::from_value::<FsRead>(v)
10571086
.unwrap()
1058-
.invoke(&os, &mut stdout)
1087+
.invoke(&os, &mut stdout, &mut HashMap::new())
10591088
.await
10601089
.unwrap();
10611090

@@ -1102,7 +1131,7 @@ mod tests {
11021131
});
11031132
let output = serde_json::from_value::<FsRead>(v)
11041133
.unwrap()
1105-
.invoke(&os, &mut stdout)
1134+
.invoke(&os, &mut stdout, &mut HashMap::new())
11061135
.await
11071136
.unwrap();
11081137

@@ -1134,7 +1163,7 @@ mod tests {
11341163
});
11351164
let output = serde_json::from_value::<FsRead>(v)
11361165
.unwrap()
1137-
.invoke(&os, &mut stdout)
1166+
.invoke(&os, &mut stdout, &mut HashMap::new())
11381167
.await
11391168
.unwrap();
11401169

@@ -1171,7 +1200,7 @@ mod tests {
11711200
});
11721201
let output = serde_json::from_value::<FsRead>(v)
11731202
.unwrap()
1174-
.invoke(&os, &mut stdout)
1203+
.invoke(&os, &mut stdout, &mut HashMap::new())
11751204
.await
11761205
.unwrap();
11771206

@@ -1195,7 +1224,7 @@ mod tests {
11951224
});
11961225
let output = serde_json::from_value::<FsRead>(v)
11971226
.unwrap()
1198-
.invoke(&os, &mut stdout)
1227+
.invoke(&os, &mut stdout, &mut HashMap::new())
11991228
.await
12001229
.unwrap();
12011230

@@ -1232,7 +1261,7 @@ mod tests {
12321261
});
12331262
let output = serde_json::from_value::<FsRead>(v)
12341263
.unwrap()
1235-
.invoke(&os, &mut stdout)
1264+
.invoke(&os, &mut stdout, &mut HashMap::new())
12361265
.await
12371266
.unwrap();
12381267

@@ -1272,7 +1301,7 @@ mod tests {
12721301
});
12731302
let output = serde_json::from_value::<FsRead>(v)
12741303
.unwrap()
1275-
.invoke(&os, &mut stdout)
1304+
.invoke(&os, &mut stdout, &mut HashMap::new())
12761305
.await
12771306
.unwrap();
12781307

@@ -1302,7 +1331,7 @@ mod tests {
13021331
});
13031332
let output = serde_json::from_value::<FsRead>(v)
13041333
.unwrap()
1305-
.invoke(&os, &mut stdout)
1334+
.invoke(&os, &mut stdout, &mut HashMap::new())
13061335
.await
13071336
.unwrap();
13081337

@@ -1321,7 +1350,7 @@ mod tests {
13211350
});
13221351
let output = serde_json::from_value::<FsRead>(v)
13231352
.unwrap()
1324-
.invoke(&os, &mut stdout)
1353+
.invoke(&os, &mut stdout, &mut HashMap::new())
13251354
.await
13261355
.unwrap();
13271356

@@ -1353,7 +1382,7 @@ mod tests {
13531382

13541383
let output = serde_json::from_value::<FsRead>(v)
13551384
.unwrap()
1356-
.invoke(&os, &mut stdout)
1385+
.invoke(&os, &mut stdout, &mut HashMap::new())
13571386
.await
13581387
.unwrap();
13591388
// All text operations should return combined text

0 commit comments

Comments
 (0)