Skip to content

Commit b8d6414

Browse files
authored
feat: add fuzzy edit_file builtin (#281)
* feat: add fuzzy edit_file builtin * fix: reject ambiguous anchor matches * fix: reject overlapping fuzzy edit matches * fix: reject overlapping replace-all matches
1 parent f6fbe0b commit b8d6414

6 files changed

Lines changed: 411 additions & 28 deletions

File tree

‎backend/openbot/bots/catalog.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class BotCatalogEntry(BaseModel):
4848
"description": "Implements changes in the repo at the workspace root and opens pull requests.",
4949
"icon": "wrench",
5050
"instruction_file": "engineer.md",
51-
"tool_names": ("run_shell", "read_file", "write_file", "list_files", "search_code"),
51+
"tool_names": ("run_shell", "read_file", "write_file", "edit_file", "list_files", "search_code"),
5252
"load_repository_instructions": True,
5353
},
5454
{

‎backend/openbot/bots/instructions/engineer.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ files unless asked to make changes.
4141

4242
</autonomy_and_questions>
4343

44+
<tool_editing>
45+
Use `read_file` and `search_code` for discovery, `edit_file` for targeted in-place changes, and `write_file` only for new or complete files. Prefer `edit_file` over rewriting an entire existing file.
46+
47+
</tool_editing>
48+
4449
<investigate_before_answering>
4550

4651
Never speculate about code you haven't opened. If the user names a file, function,

‎backend/openbot/seed.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ async def ensure_cron_actor(services) -> Actor:
7474
For each task: create a branch from the default branch, implement the change, run the tests, commit with a clear
7575
message, push, and open a PR with a proper PR description (use `gh pr create --title "<title>" --body "<body>"` or pipe the body from a file). Then reply with the PR link and a two-line summary and
7676
mention @reviewer to request review. If review feedback comes back, address it on the same branch, push, and
77-
mention @reviewer again. Never merge. Use run_shell for git and gh; use read_file/write_file/list_files for code.
77+
mention @reviewer again. Never merge. Use run_shell for git and gh; use read_file/search_code for discovery, edit_file for in-place changes, and write_file for new or complete files.
7878
You see only the messages addressed to you and your own earlier replies, not the whole thread. The hand-off should
7979
contain everything you need; if it does not, use read_history or recall_messages before asking a human. When you
8080
hand off to @reviewer, include the PR number and what changed.
@@ -93,7 +93,7 @@ async def ensure_cron_actor(services) -> Actor:
9393
CI gate before hand-off:
9494
- Before mentioning @reviewer or any other bot, run local `ruff check`/lint plus full test suites, push, then
9595
`gh pr checks <n> --watch` until all checks pass. Never request review on a red CI. CI must go green before QA picks it up.""",
96-
"tool_names": ["run_shell", "read_file", "write_file", "list_files", "search_code"], "approval_tools": [],
96+
"tool_names": ["run_shell", "read_file", "write_file", "edit_file", "list_files", "search_code"], "approval_tools": [],
9797
},
9898
{
9999
"handle": "reviewer", "name": "Reviewer",

‎backend/openbot/tools/builtin/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
update_bot_instructions,
66
)
77
from openbot.tools.builtin.bots import create_bot
8-
from openbot.tools.builtin.files import list_files, patch_file, read_file, write_file
8+
from openbot.tools.builtin.files import edit_file, list_files, patch_file, read_file, write_file
99
from openbot.tools.builtin.http import fetch_url, http_request
1010
from openbot.tools.builtin.search import search_code
1111
from openbot.tools.builtin.shell import run_shell
1212

13-
SELECTABLE_TOOLS = [run_shell, read_file, write_file, patch_file, list_files, search_code, http_request, fetch_url, create_bot,
13+
SELECTABLE_TOOLS = [run_shell, read_file, write_file, edit_file, patch_file, list_files, search_code, http_request, fetch_url, create_bot,
1414
read_bot_description, read_bot_instructions, update_bot_description, update_bot_instructions]

‎backend/openbot/tools/builtin/files.py‎

Lines changed: 222 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import asyncio
2+
import difflib
23
import os
34
import sys
5+
from itertools import pairwise
6+
from pathlib import Path
47

58
from 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+
71283
async 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

Comments
 (0)