11import asyncio
2+ import difflib
23import os
34import sys
5+ from itertools import pairwise
6+ from pathlib import Path
47
58from langchain .tools import ToolRuntime , tool
69
@@ -24,13 +27,19 @@ def _outline(path: str, lines: list[str], size: int, limit: int) -> str:
2427 shown .append (line )
2528 used += len (line ) + 1
2629 head = "\n " .join (shown )
27- return (f"{ path } : { len (lines )} lines, { size } chars; too large to show whole (cap { limit } chars). "
28- f"Read it in parts with start_line/end_line, or grep for what you need. Lines 1-{ len (shown )} :\n { head } " )
30+ return (
31+ f"{ path } : { len (lines )} lines, { size } chars; too large to show whole (cap { limit } chars). "
32+ f"Read it in parts with start_line/end_line, or grep for what you need. Lines 1-{ len (shown )} :\n { head } "
33+ )
2934
3035
3136@tool
32- async def read_file (path : str , runtime : ToolRuntime [RunContext ], start_line : int | None = None ,
33- end_line : int | None = None ) -> str :
37+ async def read_file (
38+ path : str ,
39+ runtime : ToolRuntime [RunContext ],
40+ start_line : int | None = None ,
41+ end_line : int | None = None ,
42+ ) -> str :
3443 """Read a UTF-8 text file at a path relative to the workspace root.
3544
3645 Pass `start_line` and/or `end_line` (1-based, inclusive) to read only part of a large file; the
@@ -53,7 +62,9 @@ async def read_file(path: str, runtime: ToolRuntime[RunContext], start_line: int
5362 if start > end :
5463 return f"error: no lines in range { start } -{ end } (file has { total } lines)"
5564 body = "\n " .join (lines [start - 1 :end ])
56- return cap (f"lines { start } -{ end } of { total } :\n { body } " , limit , hint = "ask for a narrower line range" )
65+ return cap (
66+ f"lines { start } -{ end } of { total } :\n { body } " , limit , hint = "ask for a narrower line range"
67+ )
5768
5869
5970@tool
@@ -68,6 +79,207 @@ async def write_file(path: str, content: str, runtime: ToolRuntime[RunContext])
6879 return f"error: { e } "
6980
7081
82+ _EDIT_LOCKS : dict [Path , asyncio .Lock ] = {}
83+
84+
85+ def _edit_lock (path : Path ) -> asyncio .Lock :
86+ return _EDIT_LOCKS .setdefault (path , asyncio .Lock ())
87+
88+
89+ def _decode_escapes (text : str ) -> str :
90+ return bytes (text , "utf-8" ).decode ("unicode_escape" )
91+
92+
93+ def _normalize_whitespace (text : str ) -> str :
94+ return " " .join (text .split ())
95+
96+
97+ def _remove_common_indent (text : str ) -> str :
98+ lines = text .split ("\n " )
99+ indents = [len (line ) - len (line .lstrip ()) for line in lines if line .strip ()]
100+ indent = min (indents , default = 0 )
101+ return "\n " .join (line [indent :] if line .strip () else line for line in lines )
102+
103+
104+ def _candidate_ranges (content : str , old : str ) -> list [tuple [int , int , str ]]:
105+ """Return (start, end, strategy) candidates, ordered from strict to fuzzy.
106+
107+ The matcher ladder is adapted from OpenCode's MIT-licensed edit tool.
108+ """
109+ if not old :
110+ return []
111+ exact = []
112+ start = 0
113+ while True :
114+ index = content .find (old , start )
115+ if index < 0 :
116+ break
117+ exact .append ((index , index + len (old ), "exact" ))
118+ start = index + 1
119+ if exact :
120+ return exact
121+
122+ content_lines = content .split ("\n " )
123+ old_lines = old .split ("\n " )
124+ if not old_lines :
125+ return []
126+ decoded_old = _decode_escapes (old )
127+ if decoded_old != old :
128+ decoded_matches = _candidate_ranges (content , decoded_old )
129+ if decoded_matches :
130+ return [(start , end , "escape-normalized" ) for start , end , _ in decoded_matches ]
131+ if len (old_lines ) == 1 :
132+ normalized_old = _normalize_whitespace (old )
133+ found : list [tuple [int , int , str ]] = []
134+ for line_number in range (len (content_lines )):
135+ for end_line in range (line_number , len (content_lines )):
136+ if end_line > line_number and not content_lines [end_line ]:
137+ continue
138+ block = "\n " .join (content_lines [line_number : end_line + 1 ])
139+ if _normalize_whitespace (block ) == normalized_old :
140+ start = sum (len (item ) + 1 for item in content_lines [:line_number ])
141+ found .append ((start , start + len (block ), "whitespace-normalized" ))
142+ if found :
143+ return found
144+ strategies = (
145+ (
146+ "trimmed lines" ,
147+ lambda block : (
148+ [line .strip () for line in block .split ("\n " )] == [line .strip () for line in old_lines ]
149+ ),
150+ ),
151+ (
152+ "whitespace-normalized" ,
153+ lambda block : _normalize_whitespace (block ) == _normalize_whitespace (old ),
154+ ),
155+ (
156+ "indentation-flexible" ,
157+ lambda block : _remove_common_indent (block ) == _remove_common_indent (old ),
158+ ),
159+ ("escape-normalized" , lambda block : block == _decode_escapes (old )),
160+ )
161+ for strategy , matches in strategies :
162+ found : list [tuple [int , int , str ]] = []
163+ for line_number in range (len (content_lines ) - len (old_lines ) + 1 ):
164+ block = "\n " .join (content_lines [line_number : line_number + len (old_lines )])
165+ if matches (block ):
166+ start = sum (len (line ) + 1 for line in content_lines [:line_number ])
167+ found .append ((start , start + len (block ), strategy ))
168+ if found :
169+ return found
170+
171+ if len (old_lines ) >= 3 :
172+ first , last = old_lines [0 ].strip (), old_lines [- 1 ].strip ()
173+ found : list [tuple [int , int , str ]] = []
174+ for line_number in range (len (content_lines )):
175+ if content_lines [line_number ].strip () != first :
176+ continue
177+ for end_line in range (line_number + 2 , len (content_lines )):
178+ if content_lines [end_line ].strip () != last :
179+ continue
180+ block_lines = content_lines [line_number : end_line + 1 ]
181+ if len (block_lines ) != len (old_lines ):
182+ continue
183+ middle = sum (
184+ a .strip () == b .strip ()
185+ for a , b in zip (block_lines [1 :- 1 ], old_lines [1 :- 1 ], strict = True )
186+ )
187+ total = sum (
188+ bool (a .strip () or b .strip ())
189+ for a , b in zip (block_lines [1 :- 1 ], old_lines [1 :- 1 ], strict = True )
190+ )
191+ if not total or middle / total >= 0.5 :
192+ start = sum (len (line ) + 1 for line in content_lines [:line_number ])
193+ block = "\n " .join (block_lines )
194+ found .append ((start , start + len (block ), "first/last-line anchors" ))
195+ if found :
196+ return found
197+ return []
198+
199+
200+ def _is_disproportionate (candidate : str , old : str ) -> bool :
201+ return len (candidate ) > max (len (old ) * 3 , len (old ) + 200 )
202+
203+
204+ def _render_edit_diff (path : str , before : str , after : str , limit : int = 4000 ) -> str :
205+ diff = "" .join (
206+ difflib .unified_diff (
207+ before .splitlines (keepends = True ),
208+ after .splitlines (keepends = True ),
209+ fromfile = path ,
210+ tofile = path ,
211+ )
212+ )
213+ return diff [:limit ] + ("\n ... diff truncated" if len (diff ) > limit else "" )
214+
215+
216+ @tool
217+ async def edit_file (
218+ path : str ,
219+ old_string : str ,
220+ new_string : str ,
221+ runtime : ToolRuntime [RunContext ],
222+ replace_all : bool = False ,
223+ ) -> str :
224+ """Replace text in a UTF-8 workspace file using a safe fuzzy matcher ladder."""
225+ try :
226+ p = resolve_in_workspace (runtime .context .workspace_root , path )
227+ except ValueError as e :
228+ return f"error: { e } "
229+ if not old_string :
230+ return "error: 'old_string' must be non-empty"
231+
232+ async with _edit_lock (p ):
233+ try :
234+ raw = p .read_bytes ()
235+ bom = raw .startswith (b"\xef \xbb \xbf " )
236+ content = raw [3 :].decode ("utf-8" ) if bom else raw .decode ("utf-8" )
237+ except (OSError , UnicodeDecodeError ) as e :
238+ return f"error: { e } "
239+
240+ newline = "\r \n " if "\r \n " in content else "\n "
241+ normalized = content .replace ("\r \n " , "\n " ).replace ("\r " , "\n " )
242+ old = old_string .replace ("\r \n " , "\n " ).replace ("\r " , "\n " )
243+ new = new_string .replace ("\r \n " , "\n " ).replace ("\r " , "\n " )
244+ matches = _candidate_ranges (normalized , old )
245+ if not matches :
246+ return "error: old_string was not found; re-read the file and provide the intended text"
247+ disproportionate = [
248+ match for match in matches if _is_disproportionate (normalized [match [0 ] : match [1 ]], old )
249+ ]
250+ if disproportionate :
251+ return "error: matched text is much larger than old_string; provide a more specific old_string"
252+ if len (matches ) > 1 and not replace_all :
253+ return f"error: old_string matched { len (matches )} locations; pass replace_all=true or provide more context"
254+ if replace_all :
255+ ordered_matches = sorted (matches , key = lambda match : match [0 ])
256+ if any (
257+ previous [1 ] > current [0 ]
258+ for previous , current in pairwise (ordered_matches )
259+ ):
260+ return "error: replace_all cannot apply overlapping matches; provide more context"
261+
262+ selected = matches if replace_all else matches [:1 ]
263+ result = normalized
264+ for start , end , _ in reversed (selected ):
265+ result = result [:start ] + new + result [end :]
266+ output = result .replace ("\n " , newline )
267+ encoded = output .encode ("utf-8" )
268+ if bom :
269+ encoded = b"\xef \xbb \xbf " + encoded
270+ try :
271+ p .write_bytes (encoded )
272+ except OSError as e :
273+ return f"error: { e } "
274+ strategy = matches [0 ][2 ]
275+ diff = _render_edit_diff (path , normalized , result )
276+ return (
277+ f"edited { path } ({ strategy } , { len (selected )} replacement{ 's' if len (selected ) != 1 else '' } )\n { diff } "
278+ if diff
279+ else f"edited { path } ({ strategy } )"
280+ )
281+
282+
71283async def _apply_patch_command (
72284 patch_content : str ,
73285 path : str | None ,
@@ -197,4 +409,8 @@ async def list_files(runtime: ToolRuntime[RunContext], path: str = ".", depth: i
197409 rel = os .path .relpath (dirpath , root )
198410 for f in sorted (filenames ):
199411 lines .append (f if rel == "." else f"{ rel } /{ f } " )
200- return cap ("\n " .join (lines ) or "(empty)" , runtime .context .tool_output_cap , hint = "list a subdirectory or a smaller depth" )
412+ return cap (
413+ "\n " .join (lines ) or "(empty)" ,
414+ runtime .context .tool_output_cap ,
415+ hint = "list a subdirectory or a smaller depth" ,
416+ )
0 commit comments