Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 73 additions & 9 deletions src/agent_harness/edits.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,57 @@ def _occurrences(text: str, needle: str) -> list[int]:
return found


def _reindented(text: str, needle: str) -> tuple[int, int, str] | None:
"""One region matching `needle` ignoring how far each line is indented.

Returns `(start, end, indent_delta)` for a **unique** match, else None.

Why this is not the guessing the exact matcher refuses. A model that
reproduces the right lines but shifts them all by two spaces has named a
location correctly and formatted it wrongly; refusing that costs an item
for a mistake with no ambiguity in it. What is still refused is anything
with a *choice* in it: the stripped lines must correspond one for one, the
relative shape must be preserved (every line moves by the same amount),
and it must match in exactly one place. Two candidates is still not a
location, and is still refused.

Blank lines are compared as blank regardless of trailing whitespace,
because an editor stripping it is not a difference anybody means.
"""
want = needle.split("\n")
have = text.split("\n")
if not want:
return None
stripped = [line.strip() for line in want]

hits: list[int] = []
for first in range(len(have) - len(want) + 1):
window = have[first : first + len(want)]
if [line.strip() for line in window] != stripped:
continue
# Every line must move by the same amount, or this is a different
# shape wearing the same words -- which is a choice, so it is refused.
deltas = {
len(line) - len(line.lstrip()) - (len(w) - len(w.lstrip()))
for line, w in zip(window, want, strict=True)
if line.strip()
}
if len(deltas) == 1:
hits.append(first)

if len(hits) != 1:
return None
first = hits[0]
shift = next(
len(line) - len(line.lstrip()) - (len(w) - len(w.lstrip()))
for line, w in zip(have[first : first + len(want)], want, strict=True)
if line.strip()
)
start = sum(len(line) + 1 for line in have[:first])
end = start + sum(len(line) + 1 for line in have[first : first + len(want)]) - 1
return start, end, " " * shift if shift > 0 else ""


@dataclass(frozen=True)
class Edit:
"""One exact-text replacement in one file."""
Expand Down Expand Up @@ -192,21 +243,34 @@ def plan_edits(root: Path, edits: list[Edit]) -> dict[str, tuple[str, str]]:
pending[target] = edit.replace
else:
search = edit.search.rstrip("\n")
replace = edit.replace.rstrip("\n")
at = _occurrences(current, search)
if not at:
raise EditError(
f"{where}: the SEARCH text does not occur in the file as whole lines. "
f"It must match exactly, including indentation."
)
if len(at) > 1:
raise EditError(
f"{where}: the SEARCH text occurs {len(at)} times and is therefore not a "
f"location. Include enough surrounding lines to make it unique."
)
cut = at[0]
pending[target] = (
current[:cut] + edit.replace.rstrip("\n") + current[cut + len(search) :]
)
if at:
cut = at[0]
pending[target] = current[:cut] + replace + current[cut + len(search) :]
else:
# Exact match failed. Before refusing, allow the one difference
# that carries no ambiguity: the same lines, uniquely located,
# indented differently. The replacement is shifted by the same
# amount so the result keeps the file's own indentation rather
# than the model's.
loose = _reindented(current, search)
if loose is None:
raise EditError(
f"{where}: the SEARCH text does not occur in the file as whole "
f"lines, even ignoring indentation. It must reproduce the "
f"existing text exactly."
)
start, end, shift = loose
shifted = "\n".join(
(shift + line) if line.strip() else line for line in replace.split("\n")
)
pending[target] = current[:start] + shifted + current[end:]

if edit.path not in order:
order.append(edit.path)
Expand Down
35 changes: 29 additions & 6 deletions tests/test_edits.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,39 @@ def test_an_empty_search_creates_a_file(tmp_path: Path) -> None:
assert (tmp_path / "new/deep/file.rs").read_text() == "fn main() {}\n"


def test_indentation_must_match(tmp_path: Path) -> None:
"""Whitespace is part of the text. A near-miss is a miss.
def test_a_uniform_indentation_shift_is_tolerated_when_it_is_unique(tmp_path: Path) -> None:
"""A model that named the right lines and mis-indented them has not guessed.

Being lenient here would mean guessing which of several similar lines the
model meant, which is the ambiguity this format exists to remove.
Refusing this costs an item for a mistake with no ambiguity in it, which
was the dominant failure once hunk arithmetic was removed. The replacement
is shifted to match the file, so the result keeps the file's indentation
rather than the model's.
"""
target = tmp_path / "lib.rs"
target.write_text(" indented = 1\n")
target.write_text("fn a() {\n let x = 1;\n let y = 2;\n}\n")
apply_edits(tmp_path, [Edit("lib.rs", "let x = 1;\nlet y = 2;", "let x = 9;\nlet y = 8;")])
assert target.read_text() == "fn a() {\n let x = 9;\n let y = 8;\n}\n"


def test_a_shift_is_refused_when_it_could_mean_two_places(tmp_path: Path) -> None:
"""Uniqueness is not relaxed. Two candidates is still not a location."""
target = tmp_path / "lib.rs"
target.write_text("fn a() {\n call();\n}\nfn b() {\n call();\n}\n")
with pytest.raises(EditError, match="does not occur|occurs 2 times"):
apply_edits(tmp_path, [Edit("lib.rs", "call();", "other();")])
assert target.read_text().count("other();") == 0


def test_a_changed_shape_is_refused_not_reindented(tmp_path: Path) -> None:
"""Lines must move together, or it is a different shape, not a shift.

Here the model's block has both lines flush; the file nests the second.
Accepting that would be choosing an indentation nobody wrote.
"""
target = tmp_path / "lib.rs"
target.write_text(" if x {\n y();\n")
with pytest.raises(EditError, match="does not occur"):
apply_edits(tmp_path, [Edit("lib.rs", "indented = 1", "indented = 2")])
apply_edits(tmp_path, [Edit("lib.rs", "if x {\ny();", "if x {\nz();")])


# ------------------------------------------------------------ refusing
Expand Down
Loading