@@ -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+
440501def 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 )
0 commit comments