From fc482ca76fbe6dba5b621ba5d64d4a2532e2b550 Mon Sep 17 00:00:00 2001 From: sandikodev Date: Sun, 5 Apr 2026 00:37:15 +0700 Subject: [PATCH] feat(fs_write): fuzzy str_replace with 3-strategy fallback chain + file freshness check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/chat-cli/src/cli/chat/line_tracker.rs | 7 + crates/chat-cli/src/cli/chat/tools/fs_read.rs | 59 ++- .../chat-cli/src/cli/chat/tools/fs_write.rs | 359 +++++++++++++++++- crates/chat-cli/src/cli/chat/tools/mod.rs | 2 +- .../src/cli/chat/tools/tool_index.json | 125 +++--- 5 files changed, 464 insertions(+), 88 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/line_tracker.rs b/crates/chat-cli/src/cli/chat/line_tracker.rs index 1717d16fe7..e5b13d2f73 100644 --- a/crates/chat-cli/src/cli/chat/line_tracker.rs +++ b/crates/chat-cli/src/cli/chat/line_tracker.rs @@ -1,3 +1,5 @@ +use std::time::SystemTime; + use serde::{ Deserialize, Serialize, @@ -19,6 +21,10 @@ pub struct FileLineTracker { pub lines_removed_by_agent: usize, /// Whether or not this is the first `fs_write` invocation pub is_first_write: bool, + /// mtime of the file at the time it was last read by `fs_read`. + /// Used by `fs_write` to detect external modifications between read and write. + #[serde(skip)] + pub last_read_mtime: Option, } impl Default for FileLineTracker { @@ -30,6 +36,7 @@ impl Default for FileLineTracker { lines_added_by_agent: 0, lines_removed_by_agent: 0, is_first_write: true, + last_read_mtime: None, } } } diff --git a/crates/chat-cli/src/cli/chat/tools/fs_read.rs b/crates/chat-cli/src/cli/chat/tools/fs_read.rs index b08b270431..e0482def65 100644 --- a/crates/chat-cli/src/cli/chat/tools/fs_read.rs +++ b/crates/chat-cli/src/cli/chat/tools/fs_read.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::collections::VecDeque; use std::fs::Metadata; use std::io::Write; @@ -43,6 +44,7 @@ use crate::cli::chat::{ CONTINUATION_LINE, sanitize_unicode_tags, }; +use crate::cli::chat::line_tracker::FileLineTracker; use crate::os::Os; use crate::theme::StyledText; use crate::util::paths; @@ -266,7 +268,25 @@ impl FsRead { } } - pub async fn invoke(&self, os: &Os, updates: &mut impl Write) -> Result { + pub async fn invoke( + &self, + os: &Os, + updates: &mut impl Write, + line_tracker: &mut HashMap, + ) -> Result { + // Record mtime for each file-read operation so fs_write can detect + // external modifications between read and write. + for op in &self.operations { + if let Some(path) = op.file_path(os) { + if let Ok(meta) = std::fs::metadata(&path) { + if let Ok(mtime) = meta.modified() { + let key = path.to_string_lossy().to_string(); + line_tracker.entry(key).or_default().last_read_mtime = Some(mtime); + } + } + } + } + if self.operations.len() == 1 { // Single operation - return result directly self.operations[0].invoke(os, updates).await @@ -358,6 +378,15 @@ impl FsRead { } impl FsReadOperation { + /// Returns the resolved file path for Line operations (the only type that reads file content). + /// Used to record mtime for freshness checking in fs_write. + pub fn file_path(&self, os: &Os) -> Option { + match self { + FsReadOperation::Line(fs_line) => Some(sanitize_path_tool_arg(os, &fs_line.path)), + _ => None, + } + } + pub async fn validate(&mut self, os: &Os) -> Result<()> { match self { FsReadOperation::Line(fs_line) => fs_line.validate(os).await, @@ -943,7 +972,7 @@ mod tests { }); let output = serde_json::from_value::(v) .unwrap() - .invoke(&os, &mut stdout) + .invoke(&os, &mut stdout, &mut HashMap::new()) .await .unwrap(); @@ -977,7 +1006,7 @@ mod tests { assert!( serde_json::from_value::(v) .unwrap() - .invoke(&os, &mut stdout) + .invoke(&os, &mut stdout, &mut HashMap::new()) .await .is_err() ); @@ -1010,7 +1039,7 @@ mod tests { }]}); let output = serde_json::from_value::(v) .unwrap() - .invoke(&os, &mut stdout) + .invoke(&os, &mut stdout, &mut HashMap::new()) .await .unwrap(); @@ -1029,7 +1058,7 @@ mod tests { }); let output = serde_json::from_value::(v) .unwrap() - .invoke(&os, &mut stdout) + .invoke(&os, &mut stdout, &mut HashMap::new()) .await .unwrap(); @@ -1055,7 +1084,7 @@ mod tests { let v = serde_json::json!($value); let output = serde_json::from_value::(v) .unwrap() - .invoke(&os, &mut stdout) + .invoke(&os, &mut stdout, &mut HashMap::new()) .await .unwrap(); @@ -1102,7 +1131,7 @@ mod tests { }); let output = serde_json::from_value::(v) .unwrap() - .invoke(&os, &mut stdout) + .invoke(&os, &mut stdout, &mut HashMap::new()) .await .unwrap(); @@ -1134,7 +1163,7 @@ mod tests { }); let output = serde_json::from_value::(v) .unwrap() - .invoke(&os, &mut stdout) + .invoke(&os, &mut stdout, &mut HashMap::new()) .await .unwrap(); @@ -1171,7 +1200,7 @@ mod tests { }); let output = serde_json::from_value::(v) .unwrap() - .invoke(&os, &mut stdout) + .invoke(&os, &mut stdout, &mut HashMap::new()) .await .unwrap(); @@ -1195,7 +1224,7 @@ mod tests { }); let output = serde_json::from_value::(v) .unwrap() - .invoke(&os, &mut stdout) + .invoke(&os, &mut stdout, &mut HashMap::new()) .await .unwrap(); @@ -1232,7 +1261,7 @@ mod tests { }); let output = serde_json::from_value::(v) .unwrap() - .invoke(&os, &mut stdout) + .invoke(&os, &mut stdout, &mut HashMap::new()) .await .unwrap(); @@ -1272,7 +1301,7 @@ mod tests { }); let output = serde_json::from_value::(v) .unwrap() - .invoke(&os, &mut stdout) + .invoke(&os, &mut stdout, &mut HashMap::new()) .await .unwrap(); @@ -1302,7 +1331,7 @@ mod tests { }); let output = serde_json::from_value::(v) .unwrap() - .invoke(&os, &mut stdout) + .invoke(&os, &mut stdout, &mut HashMap::new()) .await .unwrap(); @@ -1321,7 +1350,7 @@ mod tests { }); let output = serde_json::from_value::(v) .unwrap() - .invoke(&os, &mut stdout) + .invoke(&os, &mut stdout, &mut HashMap::new()) .await .unwrap(); @@ -1353,7 +1382,7 @@ mod tests { let output = serde_json::from_value::(v) .unwrap() - .invoke(&os, &mut stdout) + .invoke(&os, &mut stdout, &mut HashMap::new()) .await .unwrap(); // All text operations should return combined text diff --git a/crates/chat-cli/src/cli/chat/tools/fs_write.rs b/crates/chat-cli/src/cli/chat/tools/fs_write.rs index 09a64058a1..15b70a0281 100644 --- a/crates/chat-cli/src/cli/chat/tools/fs_write.rs +++ b/crates/chat-cli/src/cli/chat/tools/fs_write.rs @@ -132,8 +132,23 @@ impl FsWrite { write_to_file(os, &path, file_text).await?; }, FsWrite::StrReplace { old_str, new_str, .. } => { + // Freshness check: if the file was read earlier in this session, verify it + // hasn't been modified externally since then. + let path_key = path.to_string_lossy().to_string(); + if let Some(tracker) = line_tracker.get(&path_key) { + if let Some(read_mtime) = tracker.last_read_mtime { + if let Ok(current_mtime) = std::fs::metadata(&path).and_then(|m| m.modified()) { + if current_mtime > read_mtime { + return Err(eyre!( + "file '{}' was modified externally after it was last read — \ + use fs_read to re-read the current content before retrying str_replace", + path.display() + )); + } + } + } + } let file = os.fs.read_to_string(&path).await?; - let matches = file.match_indices(old_str).collect::>(); queue!( output, style::Print("Updating: "), @@ -142,14 +157,8 @@ impl FsWrite { StyledText::reset(), style::Print("\n"), )?; - match matches.len() { - 0 => return Err(eyre!("no occurrences of \"{old_str}\" were found")), - 1 => { - let file = file.replacen(old_str, new_str, 1); - os.fs.write(&path, file).await?; - }, - x => return Err(eyre!("{x} occurrences of old_str were found when only 1 is expected")), - } + let updated = str_replace_fuzzy(&file, old_str, new_str)?; + os.fs.write(&path, updated).await?; }, FsWrite::Insert { insert_line, new_str, .. @@ -393,7 +402,16 @@ impl FsWrite { bail!("Path must not be empty") }; }, - FsWrite::StrReplace { path, .. } | FsWrite::Insert { path, .. } => { + FsWrite::StrReplace { path, old_str, .. } => { + let path = sanitize_path_tool_arg(os, path); + if !path.exists() { + bail!("The provided path must exist in order to replace or insert contents into it") + } + if old_str.trim().is_empty() { + bail!("old_str must not be empty — use fs_read to read the file first, then provide the exact text to replace") + } + }, + FsWrite::Insert { path, .. } => { let path = sanitize_path_tool_arg(os, path); if !path.exists() { bail!("The provided path must exist in order to replace or insert contents into it") @@ -858,6 +876,212 @@ fn syntect_to_crossterm_color(syntect: syntect::highlighting::Color) -> style::C } } +/// Attempts to replace `old_str` with `new_str` in `content` using a fallback chain: +/// +/// 1. **Exact match** — fastest, most precise. +/// 2. **Line-trimmed match** — matches lines after stripping leading/trailing whitespace, +/// then replaces the original (indented) text. Handles indentation drift. +/// 3. **Block-anchor match** — matches by first+last line as anchors, uses Levenshtein +/// similarity on middle lines to find the best candidate. Handles minor edits in context. +/// +/// Returns an error if no strategy finds exactly one unambiguous match. +fn str_replace_fuzzy(content: &str, old_str: &str, new_str: &str) -> eyre::Result { + // Normalize CRLF → LF for matching. Restore original line endings after replacement. + let (content_norm, content_crlf) = normalize_line_endings(content); + let (old_norm, _) = normalize_line_endings(old_str); + let (new_norm, _) = normalize_line_endings(new_str); + let content = content_norm.as_ref(); + let old_str = old_norm.as_ref(); + let new_str = new_norm.as_ref(); + + // Strategy 1: exact match + let exact_count = content.match_indices(old_str).count(); + match exact_count { + 1 => { + let result = content.replacen(old_str, new_str, 1); + return Ok(if content_crlf { result.replace('\n', "\r\n") } else { result }); + }, + x if x > 1 => { + return Err(eyre::eyre!( + "{x} occurrences of old_str were found when only 1 is expected — \ + add more surrounding context to old_str to make it unique" + )) + }, + _ => {}, + } + + // Strategies 2 & 3: fuzzy — both return a byte range to splice at + let range = line_trimmed_match(content, old_str) + .or_else(|| block_anchor_match(content, old_str)); + + if let Some((start, end)) = range { + let result = format!("{}{}{}", &content[..start], new_str, &content[end..]); + return Ok(if content_crlf { result.replace('\n', "\r\n") } else { result }); + } + + Err(eyre::eyre!( + "no occurrences of the provided old_str were found (tried exact, \ + line-trimmed, and block-anchor matching) — use fs_read to read the \ + current file content and retry str_replace with the exact text. \ + Do NOT fall back to shell commands like sed." + )) +} + +/// Normalizes line endings to `\n` and returns the normalized string along with +/// a flag indicating whether the original used CRLF. The flag is used to restore +/// the original line endings after replacement. +fn normalize_line_endings(s: &str) -> (std::borrow::Cow, bool) { + if s.contains("\r\n") { + (s.replace("\r\n", "\n").into(), true) + } else { + (s.into(), false) + } +} + +/// Strips leading and trailing empty lines from a split-by-newline vec. +fn strip_empty_boundary_lines(mut lines: Vec<&str>) -> Vec<&str> { + while lines.last().map(|l: &&str| l.trim().is_empty()).unwrap_or(false) { + lines.pop(); + } + while lines.first().map(|l: &&str| l.trim().is_empty()).unwrap_or(false) { + lines.remove(0); + } + lines +} + +/// Builds a prefix-sum table of byte offsets for lines split by `\n`. +/// `offsets[i]` = byte offset of the start of line `i` in the original string. +/// `offsets[lines.len()]` = one past the last byte (i.e. content.len() + 1 conceptually). +fn build_line_offsets(lines: &[&str]) -> Vec { + let mut offsets = Vec::with_capacity(lines.len() + 1); + offsets.push(0usize); + for line in lines { + offsets.push(offsets.last().unwrap() + line.len() + 1); // +1 for '\n' + } + offsets +} + +/// Matches `find` against `content` by comparing trimmed lines. +/// Returns the byte range `(start, end)` in `content` if exactly one match is found. +fn line_trimmed_match(content: &str, find: &str) -> Option<(usize, usize)> { + let content_lines: Vec<&str> = content.split('\n').collect(); + let search_lines = strip_empty_boundary_lines(find.split('\n').collect()); + + if search_lines.is_empty() { + return None; + } + + let offsets = build_line_offsets(&content_lines); + + let mut matches: Vec<(usize, usize)> = Vec::new(); + 'outer: for i in 0..=content_lines.len().saturating_sub(search_lines.len()) { + for (j, search_line) in search_lines.iter().enumerate() { + if content_lines[i + j].trim() != search_line.trim() { + continue 'outer; + } + } + let start = offsets[i]; + let end = offsets[i + search_lines.len()].saturating_sub(1).min(content.len()); + matches.push((start, end)); + } + + if matches.len() == 1 { Some(matches[0]) } else { None } +} + +/// Levenshtein distance between two strings (char-level, O(min(m,n)) space). +/// `a` is placed in the row dimension (longer), `b` in the column (shorter). +fn levenshtein(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + // Ensure `a` is the longer string so `b` (columns) is the smaller allocation + let (a, b) = if a.len() >= b.len() { (a, b) } else { (b, a) }; + let (m, n) = (a.len(), b.len()); + let mut prev: Vec = (0..=n).collect(); + let mut curr = vec![0usize; n + 1]; + for i in 1..=m { + curr[0] = i; + for j in 1..=n { + curr[j] = if a[i - 1] == b[j - 1] { + prev[j - 1] + } else { + 1 + prev[j].min(curr[j - 1]).min(prev[j - 1]) + }; + } + std::mem::swap(&mut prev, &mut curr); + } + prev[n] +} + +const SIMILARITY_THRESHOLD: f64 = 0.6; + +/// Matches `find` against `content` using first+last line as anchors and Levenshtein +/// similarity on middle lines. Returns the byte range `(start, end)` in `content` if +/// similarity exceeds the threshold and the match is unambiguous. +fn block_anchor_match(content: &str, find: &str) -> Option<(usize, usize)> { + let content_lines: Vec<&str> = content.split('\n').collect(); + let search_lines = strip_empty_boundary_lines(find.split('\n').collect()); + + // Need at least 2 distinct lines for anchor matching + if search_lines.len() < 2 { + return None; + } + + let first = search_lines[0].trim(); + let last = search_lines[search_lines.len() - 1].trim(); + + // Symmetric anchors (e.g. `}` / `}`) produce too many false positives + if first == last { + return None; + } + + // Build offsets once — reused for both scoring and final byte range + let offsets = build_line_offsets(&content_lines); + + // Collect candidate windows where first and last anchor lines match + let mut candidates: Vec<(usize, usize, f64)> = Vec::new(); + for i in 0..content_lines.len() { + if content_lines[i].trim() != first { continue; } + for j in (i + 1)..content_lines.len() { + if content_lines[j].trim() == last { + let score = similarity_score(&content_lines, i, j, &search_lines); + candidates.push((i, j, score)); + break; + } + } + } + + // Pick the single best candidate above the threshold + let best = candidates + .into_iter() + .filter(|&(_, _, s)| s >= SIMILARITY_THRESHOLD) + .max_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal))?; + + let start = offsets[best.0]; + let end = offsets[best.1 + 1].saturating_sub(1).min(content.len()); + Some((start, end)) +} + +/// Average Levenshtein similarity of middle lines between `search_lines` and the +/// corresponding window `content_lines[start..=end]`. +fn similarity_score(content_lines: &[&str], start: usize, end: usize, search_lines: &[&str]) -> f64 { + let middle_count = search_lines.len().saturating_sub(2); + if middle_count == 0 { return 1.0; } + + let mut total = 0.0; + let mut counted = 0; + for k in 1..search_lines.len().saturating_sub(1) { + let ci = start + k; + if ci >= end { break; } + let a = content_lines[ci].trim(); + let b = search_lines[k].trim(); + let max_len = a.chars().count().max(b.chars().count()); + if max_len == 0 { total += 1.0; counted += 1; continue; } + total += 1.0 - levenshtein(a, b) as f64 / max_len as f64; + counted += 1; + } + if counted == 0 { 1.0 } else { total / counted as f64 } +} + #[cfg(test)] mod tests { use std::collections::HashMap; @@ -870,6 +1094,121 @@ mod tests { setup_test_directory, }; + // ── str_replace_fuzzy tests ────────────────────────────────────────────── + + #[test] + fn fuzzy_exact_match() { + let content = "fn foo() {\n let x = 1;\n}\n"; + let result = str_replace_fuzzy(content, "let x = 1;", "let x = 42;").unwrap(); + assert_eq!(result, "fn foo() {\n let x = 42;\n}\n"); + } + + #[test] + fn fuzzy_exact_match_fails_on_ambiguous() { + let content = "let x = 1;\nlet x = 1;\n"; + assert!(str_replace_fuzzy(content, "let x = 1;", "let x = 2;").is_err()); + } + + #[test] + fn fuzzy_line_trimmed_handles_indentation_drift() { + // old_str has different indentation than the file + let content = "fn foo() {\n let x = 1;\n let y = 2;\n}\n"; + let old_str = "let x = 1;\nlet y = 2;"; // no indentation + let result = str_replace_fuzzy(content, old_str, "let x = 10;\nlet y = 20;").unwrap(); + assert!(result.contains("let x = 10;")); + assert!(result.contains("let y = 20;")); + } + + #[test] + fn fuzzy_block_anchor_handles_minor_middle_edits() { + // Middle line has a minor typo vs what's in the file + let content = "fn calculate() {\n let result = a + b;\n return result;\n}\n"; + // old_str has slightly different middle line + let old_str = "fn calculate() {\n let result = a + b; // sum\n return result;\n}"; + let result = str_replace_fuzzy(content, old_str, "fn calculate() {\n return a + b;\n}"); + // Should find a match via block anchor (first+last line match) + assert!(result.is_ok(), "block anchor should match: {:?}", result); + } + + #[test] + fn fuzzy_handles_crlf_file_with_lf_old_str() { + // File uses CRLF, model sends LF — should match and preserve CRLF in output + let content = "fn foo() {\r\n let x = 1;\r\n}\r\n"; + let old_str = "fn foo() {\n let x = 1;\n}"; + let result = str_replace_fuzzy(content, old_str, "fn foo() {\n let x = 42;\n}").unwrap(); + assert!(result.contains("\r\n"), "CRLF must be preserved in output"); + assert!(result.contains("let x = 42;"), "replacement must be applied"); + assert!(!result.contains("let x = 1;"), "old content must be gone"); + } + + #[test] + fn fuzzy_rejects_empty_old_str() { + // empty old_str should be caught at validation, not reach fuzzy matching + let result = str_replace_fuzzy("fn foo() {}", "", "fn bar() {}"); + assert!(result.is_err()); + // str_replace_fuzzy itself: exact match on "" would match everywhere, + // so it should return an ambiguous error + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("occurrences"), "should report ambiguous match: {msg}"); + } + + #[test] + fn fuzzy_returns_error_when_no_strategy_matches() { + let content = "fn foo() {}\n"; + let result = str_replace_fuzzy(content, "fn bar() {}", "fn baz() {}"); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("fs_read"), "error should mention fs_read: {msg}"); + assert!(msg.contains("sed"), "error should warn against sed: {msg}"); + } + + #[test] + fn fuzzy_replaces_correct_occurrence_when_matched_text_appears_elsewhere() { + // The fuzzy-matched substring also appears earlier in the file. + // We must replace the matched position, not the first occurrence. + let content = " let x = 1;\nfn foo() {\n let x = 1;\n let y = 2;\n}\n"; + // old_str with no indentation — line-trimmed will match the block inside fn foo + let old_str = "let x = 1;\nlet y = 2;"; + let result = str_replace_fuzzy(content, old_str, "let x = 10;\nlet y = 20;").unwrap(); + // The standalone "let x = 1;" at the top must be untouched + assert!(result.starts_with(" let x = 1;\n"), "first occurrence must be untouched"); + assert!(result.contains("let x = 10;"), "matched block must be replaced"); + } + + #[test] + fn block_anchor_skips_symmetric_first_last_lines() { + // first == last — should not produce false positive via block anchor + let content = "}\n}\n"; + let find = "}\n}"; + // block_anchor_match should return None because first == last + assert!(block_anchor_match(content, find).is_none()); + } + + #[test] + fn levenshtein_space_optimised_matches_naive() { + // Verify the O(n) space implementation gives correct results + assert_eq!(levenshtein("", "abc"), 3); + assert_eq!(levenshtein("abc", ""), 3); + assert_eq!(levenshtein("saturday", "sunday"), 3); + } + + #[test] + fn line_trimmed_match_finds_indented_block() { + let content = "class Foo {\n void bar() {\n int x = 1;\n }\n}\n"; + let find = "void bar() {\n int x = 1;\n}"; + let matched = line_trimmed_match(content, find); + assert!(matched.is_some(), "should find indented block"); + let (start, end) = matched.unwrap(); + assert!(content[start..end].contains(" void bar()"), "should preserve original indentation"); + } + + #[test] + fn line_trimmed_match_returns_none_on_ambiguous() { + let content = " foo()\n foo()\n"; + let find = "foo()"; + assert!(line_trimmed_match(content, find).is_none()); + } + #[test] fn test_fs_write_deserialize() { let path = "/my-file"; diff --git a/crates/chat-cli/src/cli/chat/tools/mod.rs b/crates/chat-cli/src/cli/chat/tools/mod.rs index 4859fbf210..747e505b0e 100644 --- a/crates/chat-cli/src/cli/chat/tools/mod.rs +++ b/crates/chat-cli/src/cli/chat/tools/mod.rs @@ -152,7 +152,7 @@ impl Tool { ) -> Result { let active_agent = agents.get_active(); match self { - Tool::FsRead(fs_read) => fs_read.invoke(os, stdout).await, + Tool::FsRead(fs_read) => fs_read.invoke(os, stdout, line_tracker).await, Tool::FsWrite(fs_write) => fs_write.invoke(os, stdout, line_tracker).await, Tool::ExecuteCommand(execute_command) => execute_command.invoke(os, stdout).await, Tool::UseAws(use_aws) => use_aws.invoke(os, stdout).await, diff --git a/crates/chat-cli/src/cli/chat/tools/tool_index.json b/crates/chat-cli/src/cli/chat/tools/tool_index.json index 6ebeb50334..a7dd00d24a 100644 --- a/crates/chat-cli/src/cli/chat/tools/tool_index.json +++ b/crates/chat-cli/src/cli/chat/tools/tool_index.json @@ -118,7 +118,7 @@ }, "fs_write": { "name": "fs_write", - "description": "A tool for creating and editing files\n * The `create` command will override the file at `path` if it already exists as a file, and otherwise create a new file\n * The `append` command will add content to the end of an existing file, automatically adding a newline if the file doesn't end with one. The file must exist.\n Notes for using the `str_replace` command:\n * The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n * If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n * The `new_str` parameter should contain the edited lines that should replace the `old_str`.", + "description": "A tool for creating and editing files\n * The `create` command will override the file at `path` if it already exists as a file, and otherwise create a new file\n * The `append` command will add content to the end of an existing file, automatically adding a newline if the file does not end with one. The file must exist.\n Notes for using the `str_replace` command:\n * ALWAYS use `fs_read` to read the file content BEFORE calling `str_replace`. Never guess or reconstruct `old_str` from memory.\n * The `old_str` parameter should match one or more consecutive lines from the original file. Exact whitespace matching is preferred but minor indentation differences are tolerated.\n * If the `old_str` parameter is not unique in the file, the replacement will not be performed. Include enough surrounding context to make it unique.\n * The `new_str` parameter should contain the edited lines that should replace the `old_str`.\n * If `str_replace` fails, do NOT fall back to shell commands like `sed`. Re-read the file with `fs_read` and retry with the correct content.", "input_schema": { "type": "object", "properties": { @@ -305,10 +305,10 @@ "command": { "type": "string", "enum": [ - "create", - "complete", - "load", - "add", + "create", + "complete", + "load", + "add", "remove", "lookup" ], @@ -377,7 +377,9 @@ } } }, - "required": ["command"] + "required": [ + "command" + ] } }, "delegate": { @@ -385,64 +387,63 @@ "description": "Launch and manage asynchronous agent processes. This tool allows you to delegate tasks to agents that run independently in the background.\n\nOperations:\n- launch: Start a new task with an agent (requires task parameter, agent is optional)\n- status: Check agent status and get full output if completed. Agent is optional - defaults to 'all' if not specified\n\nIf no agent is specified for launch, uses 'default_agent'. Only one task can run per agent at a time. Files are stored in ~/.aws/amazonq/.subagents/\n\nIMPORTANT: If a specific agent is requested but not found, DO NOT automatically retry with 'default_agent' or any other agent. Simply report the error and available agents to the user.\n\nExample usage:\n1. Launch with agent: {\"operation\": \"launch\", \"agent\": \"rust-agent\", \"task\": \"Create a snake game\"}\n2. Launch without agent: {\"operation\": \"launch\", \"task\": \"Write a Python script\"}\n3. Check specific agent: {\"operation\": \"status\", \"agent\": \"rust-agent\"}\n4. Check all agents: {\"operation\": \"status\", \"agent\": \"all\"}\n5. Check all agents (shorthand): {\"operation\": \"status\"}", "input_schema": { "type": "object", - "properties": { - "operation": { - "description": "Operation to perform: launch, status, or list", - "$ref": "#/$defs/Operation" - }, - "agent": { - "description": "Agent name to use (optional - uses \"q_cli_default\" if not specified)", - "type": [ - "string", - "null" - ], - "default": null - }, - "task": { - "description": "Task description (required for launch operation). This process is supposed to be async. DO NOT query immediately after launching a task.", - "type": [ - "string", - "null" - ], - "default": null - } + "properties": { + "operation": { + "description": "Operation to perform: launch, status, or list", + "$ref": "#/$defs/Operation" + }, + "agent": { + "description": "Agent name to use (optional - uses \"q_cli_default\" if not specified)", + "type": [ + "string", + "null" + ], + "default": null }, - "required": [ - "operation" - ], - "$defs": { - "Operation": { - "oneOf": [ - { - "description": "Launch a new agent with a specified task", - "type": "string", - "const": "launch" - }, - { - "description": "Check the status of a specific agent or all agents if None is provided", - "type": "object", - "properties": { - "status": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "status" - ], - "additionalProperties": false + "task": { + "description": "Task description (required for launch operation). This process is supposed to be async. DO NOT query immediately after launching a task.", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "operation" + ], + "$defs": { + "Operation": { + "oneOf": [ + { + "description": "Launch a new agent with a specified task", + "type": "string", + "const": "launch" + }, + { + "description": "Check the status of a specific agent or all agents if None is provided", + "type": "object", + "properties": { + "status": { + "type": [ + "string", + "null" + ] + } }, - { - "description": "List all available agents", - "type": "string", - "const": "list" - } - ] - } - }, - "required": ["operation"] + "required": [ + "status" + ], + "additionalProperties": false + }, + { + "description": "List all available agents", + "type": "string", + "const": "list" + } + ] + } + } } } }