Skip to content

Commit dcce796

Browse files
committed
Adjusted edit_file tool
1 parent a7fa842 commit dcce796

2 files changed

Lines changed: 218 additions & 19 deletions

File tree

‎render_machine/agent/tools.py‎

Lines changed: 97 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,8 @@ def _do_exact_match(
347347
return (
348348
f"Error: Search text found {count} times in '{full_path}', at:\n{listed}{suffix}\n"
349349
f"Extend the search string with surrounding lines so it uniquely identifies "
350-
f"the occurrence to replace."
350+
f"the occurrence to replace — or, if EVERY occurrence should receive the same "
351+
f"replacement, retry with replace_all=true."
351352
)
352353

353354
new_content = original_content.replace(search_text, replace_text, 1)
@@ -363,33 +364,77 @@ def _do_exact_match(
363364
return f"Error: failed to write to '{full_path}': {e}"
364365

365366

366-
def _do_fuzzy_match(
367-
full_path: str, original_content: str, search_text: str, replace_text: str, render_context: RenderContext
368-
) -> str:
369-
search_lines = search_text.splitlines()
370-
content_lines = original_content.splitlines()
367+
def _normalize_fuzzy_line(line: str) -> str:
368+
"""Collapse whitespace so indentation/tab differences don't defeat line matching."""
369+
return " ".join(line.split())
371370

372-
if not search_lines:
373-
return "Error: Search text is empty or contains only whitespace"
374371

375-
best_ratio = 0.0
376-
best_match_start = -1
377-
best_match_end = -1
372+
def _find_best_fuzzy_window(search_norm: list[str], content_norm: list[str]) -> tuple[float, int, int]:
373+
"""Best (ratio, start, end) window of content lines for the search block.
374+
375+
Lines are compared as whitespace-normalized atoms. The search side is set as the
376+
matcher's cached sequence, and each window is pre-screened with the cheap
377+
real_quick_ratio/quick_ratio upper bounds — a window whose upper bound cannot beat
378+
the current best is skipped without computing the full ratio. This preserves the
379+
exact best-window result while making large-file sweeps cheap.
380+
"""
381+
best_ratio, best_start, best_end = 0.0, -1, -1
382+
matcher = difflib.SequenceMatcher(None)
383+
matcher.set_seq2(search_norm)
378384

379385
# Also try windows slightly shorter/longer than the search text: a fixed-size
380386
# window can never match when the file differs from the search block by an
381387
# inserted or deleted line, no matter how similar the rest is.
382-
window_sizes = {size for size in range(len(search_lines) - 2, len(search_lines) + 3) if size >= 1}
388+
window_sizes = {size for size in range(len(search_norm) - 2, len(search_norm) + 3) if size >= 1}
383389
for window_size in window_sizes:
384-
for i in range(len(content_lines) - window_size + 1):
385-
window = content_lines[i : i + window_size]
386-
ratio = difflib.SequenceMatcher(None, search_lines, window).ratio()
390+
for i in range(len(content_norm) - window_size + 1):
391+
matcher.set_seq1(content_norm[i : i + window_size])
392+
if matcher.real_quick_ratio() <= best_ratio or matcher.quick_ratio() <= best_ratio:
393+
continue
394+
ratio = matcher.ratio()
387395
if ratio > best_ratio:
388-
best_ratio = ratio
389-
best_match_start = i
390-
best_match_end = i + window_size
396+
best_ratio, best_start, best_end = ratio, i, i + window_size
397+
return best_ratio, best_start, best_end
398+
399+
400+
def _is_single_line_drift(window_norm: list[str], search_norm: list[str]) -> bool:
401+
"""True when the window differs from the search block by exactly one line.
402+
403+
The line-atom ratio punishes a single modified line hard (one changed line in an
404+
8-line block scores 0.875, under the 0.9 bar), yet that is the most common way an
405+
agent's search text goes stale. Accept the match when blocks of 4+ lines differ by
406+
exactly one line — and, for a modified (rather than inserted/deleted) line, only
407+
when the two variants are themselves clearly similar.
408+
"""
409+
if min(len(window_norm), len(search_norm)) < 4:
410+
return False
411+
diffs = [op for op in difflib.SequenceMatcher(None, window_norm, search_norm).get_opcodes() if op[0] != "equal"]
412+
if len(diffs) != 1:
413+
return False
414+
_tag, i1, i2, j1, j2 = diffs[0]
415+
if max(i2 - i1, j2 - j1) != 1:
416+
return False
417+
if i2 - i1 == 1 and j2 - j1 == 1:
418+
return difflib.SequenceMatcher(None, window_norm[i1], search_norm[j1]).ratio() > 0.8
419+
return True
420+
421+
422+
def _do_fuzzy_match(
423+
full_path: str, original_content: str, search_text: str, replace_text: str, render_context: RenderContext
424+
) -> str:
425+
search_norm = [_normalize_fuzzy_line(line) for line in search_text.splitlines()]
426+
content_lines = original_content.splitlines()
427+
content_norm = [_normalize_fuzzy_line(line) for line in content_lines]
428+
429+
if not search_norm:
430+
return "Error: Search text is empty or contains only whitespace"
391431

392-
if best_ratio > 0.9:
432+
best_ratio, best_match_start, best_match_end = _find_best_fuzzy_window(search_norm, content_norm)
433+
434+
accepted = best_ratio > 0.9 or (
435+
best_match_start >= 0 and _is_single_line_drift(content_norm[best_match_start:best_match_end], search_norm)
436+
)
437+
if accepted:
393438
matched_text = "\n".join(content_lines[best_match_start:best_match_end])
394439
before = "\n".join(content_lines[:best_match_start])
395440
after = "\n".join(content_lines[best_match_end:])
@@ -437,6 +482,22 @@ def _do_fuzzy_match(
437482
)
438483

439484

485+
def _do_replace_all(
486+
full_path: str, original_content: str, search_text: str, replace_text: str, render_context: RenderContext
487+
) -> str:
488+
count = original_content.count(search_text)
489+
new_content = original_content.replace(search_text, replace_text)
490+
_track_file_change(full_path, render_context)
491+
try:
492+
with open(full_path, "w", encoding="utf-8") as f:
493+
f.write(new_content)
494+
return f"Successfully edited '{full_path}': replaced {count} occurrence(s) of the search text (replace_all)"
495+
except PermissionError as e:
496+
return f"Error: Permission denied writing to '{full_path}': {e}"
497+
except Exception as e:
498+
return f"Error: failed to write to '{full_path}': {e}"
499+
500+
440501
def edit_file(args: dict, render_context: RenderContext) -> str:
441502
"""Edit a file using search and replace with fuzzy matching for robustness.
442503
@@ -445,6 +506,9 @@ def edit_file(args: dict, render_context: RenderContext) -> str:
445506
- file_path (str): Path to the file to edit
446507
- search (str): Text to search for in the file
447508
- replace (str): Text to replace the search text with
509+
- replace_all (bool, optional): Replace every exact occurrence of the
510+
search text instead of requiring uniqueness. Exact match only — fuzzy
511+
matching is disabled for replace_all.
448512
render_context: The current render context
449513
450514
Returns:
@@ -453,6 +517,9 @@ def edit_file(args: dict, render_context: RenderContext) -> str:
453517
file_path = args.get("file_path", "")
454518
search_text = args.get("search", "")
455519
replace_text = args.get("replace", "")
520+
replace_all = args.get("replace_all", False)
521+
if isinstance(replace_all, str): # models sometimes send booleans as strings
522+
replace_all = replace_all.strip().lower() == "true"
456523

457524
if not file_path:
458525
return "Error: file_path is required"
@@ -495,8 +562,19 @@ def edit_file(args: dict, render_context: RenderContext) -> str:
495562

496563
# Try exact match first
497564
if search_text in original_content:
565+
if replace_all:
566+
return _do_replace_all(full_path, original_content, search_text, replace_text, render_context)
498567
return _do_exact_match(full_path, original_content, search_text, replace_text, render_context)
499568

569+
if replace_all:
570+
# Fuzzy-replacing every "similar" region is far too dangerous — replace_all
571+
# demands exact text so the blast radius is exactly what the agent asked for.
572+
return (
573+
f"Error: replace_all requires the search text to appear verbatim, and it was not found in "
574+
f"'{full_path}'. Read the file and retry with the exact text (fuzzy matching is disabled "
575+
f"for replace_all)."
576+
)
577+
500578
# Exact match failed - try fuzzy matching with whitespace normalization
501579
# This handles minor whitespace differences
502580
return _do_fuzzy_match(full_path, original_content, search_text, replace_text, render_context)

‎tests/test_agent_tools.py‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,124 @@ def test_write_file_new_file_lands_in_build_tree(project_dir, monkeypatch):
431431
assert "Successfully wrote" in result
432432
assert os.path.exists(os.path.join(build_folder, "src", "main", "java", "New.java"))
433433
assert not os.path.exists(os.path.join(project_dir, "src"))
434+
435+
436+
def test_edit_file_replace_all_replaces_every_occurrence(project_dir):
437+
build_folder = os.path.join(project_dir, "build")
438+
os.makedirs(build_folder)
439+
file_path = os.path.join(build_folder, "app.py")
440+
Path(file_path).write_text("old_name(1)\nkeep()\nold_name(2)\nold_name(3)\n", encoding="utf-8")
441+
442+
result = tools.edit_file(
443+
{"file_path": file_path, "search": "old_name", "replace": "new_name", "replace_all": True},
444+
_fake_render_context(build_folder),
445+
)
446+
447+
assert "replaced 3 occurrence(s)" in result
448+
content = Path(file_path).read_text(encoding="utf-8")
449+
assert "old_name" not in content and content.count("new_name") == 3
450+
451+
452+
def test_edit_file_multiple_matches_suggests_replace_all(project_dir):
453+
build_folder = os.path.join(project_dir, "build")
454+
os.makedirs(build_folder)
455+
file_path = os.path.join(build_folder, "app.py")
456+
Path(file_path).write_text("x = 1\ny = 1\n", encoding="utf-8")
457+
458+
result = tools.edit_file(
459+
{"file_path": file_path, "search": "= 1", "replace": "= 2"},
460+
_fake_render_context(build_folder),
461+
)
462+
463+
assert result.startswith("Error: Search text found 2 times")
464+
assert "replace_all=true" in result
465+
466+
467+
def test_edit_file_replace_all_requires_exact_text(project_dir):
468+
"""replace_all never fuzzy-matches — a non-verbatim search errors cleanly."""
469+
build_folder = os.path.join(project_dir, "build")
470+
os.makedirs(build_folder)
471+
file_path = os.path.join(build_folder, "app.py")
472+
Path(file_path).write_text("def compute():\n return 1\n", encoding="utf-8")
473+
474+
result = tools.edit_file(
475+
{"file_path": file_path, "search": "def compute():", "replace": "x", "replace_all": True},
476+
_fake_render_context(build_folder),
477+
)
478+
479+
assert result.startswith("Error: replace_all requires the search text to appear verbatim")
480+
assert "def compute():" in Path(file_path).read_text(encoding="utf-8")
481+
482+
483+
def test_edit_file_fuzzy_matches_single_modified_line(project_dir):
484+
"""One stale line in a 4+-line search block — the most common way an agent's
485+
search text goes stale — must still match."""
486+
build_folder = os.path.join(project_dir, "build")
487+
os.makedirs(build_folder)
488+
file_path = os.path.join(build_folder, "app.py")
489+
file_lines = [
490+
"def process(data):",
491+
" validate(data)",
492+
" normalized = normalize(data)",
493+
" enriched = enrich(normalized)",
494+
" result = transform(enriched)",
495+
" audit(result)",
496+
" return result",
497+
]
498+
Path(file_path).write_text("\n".join(file_lines) + "\n", encoding="utf-8")
499+
500+
stale = list(file_lines)
501+
stale[3] = " enriched = enrich(normalized, config)" # one slightly-off line
502+
result = tools.edit_file(
503+
{"file_path": file_path, "search": "\n".join(stale), "replace": "REPLACED"},
504+
_fake_render_context(build_folder),
505+
)
506+
507+
assert "Successfully edited" in result and "fuzzy match" in result
508+
assert Path(file_path).read_text(encoding="utf-8") == "REPLACED\n"
509+
510+
511+
def test_edit_file_fuzzy_two_modified_lines_still_miss(project_dir):
512+
"""Two modified lines stay below the acceptance bar — the guard against
513+
replacing a region the agent misremembers."""
514+
build_folder = os.path.join(project_dir, "build")
515+
os.makedirs(build_folder)
516+
file_path = os.path.join(build_folder, "app.py")
517+
file_lines = [
518+
"def compute_total(items):",
519+
" total = 0",
520+
" for item in items:",
521+
" total += item.price * item.quantity",
522+
" return total",
523+
]
524+
Path(file_path).write_text("\n".join(file_lines) + "\n", encoding="utf-8")
525+
526+
stale = list(file_lines)
527+
stale[1] = " total = 0.0"
528+
stale[3] = " total += item.price * item.qty"
529+
result = tools.edit_file(
530+
{"file_path": file_path, "search": "\n".join(stale), "replace": "x"},
531+
_fake_render_context(build_folder),
532+
)
533+
534+
assert result.startswith("Error: Search text not found")
535+
536+
537+
def test_edit_file_fuzzy_tolerates_indentation_differences(project_dir):
538+
"""Whitespace-normalized line matching: tabs-vs-spaces must not defeat the match."""
539+
build_folder = os.path.join(project_dir, "build")
540+
os.makedirs(build_folder)
541+
file_path = os.path.join(build_folder, "app.py")
542+
Path(file_path).write_text("def f():\n\tvalidate()\n\treturn 1\n", encoding="utf-8")
543+
544+
result = tools.edit_file(
545+
{
546+
"file_path": file_path,
547+
"search": "def f():\n validate()\n return 1",
548+
"replace": "def f():\n return 2",
549+
},
550+
_fake_render_context(build_folder),
551+
)
552+
553+
assert "Successfully edited" in result
554+
assert "return 2" in Path(file_path).read_text(encoding="utf-8")

0 commit comments

Comments
 (0)