mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
feat(patch): add 'did you mean?' feedback when patch fails to match
When patch_replace() cannot find old_string in a file, the error message now includes the closest matching lines from the file with line numbers and context. This helps the LLM self-correct without a separate read_file call. Implements Phase 1 of #536: enhanced patch error feedback with no architectural changes. - tools/fuzzy_match.py: new find_closest_lines() using SequenceMatcher - tools/file_operations.py: attach closest-lines hint to patch errors - tests/tools/test_fuzzy_match.py: 5 new tests for find_closest_lines
This commit is contained in:
@@ -230,3 +230,35 @@ class TestEscapeDriftGuard:
|
|||||||
new, count, strategy, err = fuzzy_find_and_replace(content, old_string, new_string)
|
new, count, strategy, err = fuzzy_find_and_replace(content, old_string, new_string)
|
||||||
assert err is None
|
assert err is None
|
||||||
assert count == 1
|
assert count == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindClosestLines:
|
||||||
|
def setup_method(self):
|
||||||
|
from tools.fuzzy_match import find_closest_lines
|
||||||
|
self.find_closest_lines = find_closest_lines
|
||||||
|
|
||||||
|
def test_finds_similar_line(self):
|
||||||
|
content = "def foo():\n pass\ndef bar():\n return 1\n"
|
||||||
|
result = self.find_closest_lines("def baz():", content)
|
||||||
|
assert "def foo" in result or "def bar" in result
|
||||||
|
|
||||||
|
def test_returns_empty_for_no_match(self):
|
||||||
|
content = "completely different content here"
|
||||||
|
result = self.find_closest_lines("xyzzy_no_match_possible_!!!", content)
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_returns_empty_for_empty_inputs(self):
|
||||||
|
assert self.find_closest_lines("", "some content") == ""
|
||||||
|
assert self.find_closest_lines("old string", "") == ""
|
||||||
|
|
||||||
|
def test_includes_context_lines(self):
|
||||||
|
content = "line1\nline2\ndef target():\n pass\nline5\n"
|
||||||
|
result = self.find_closest_lines("def target():", content)
|
||||||
|
assert "target" in result
|
||||||
|
|
||||||
|
def test_includes_line_numbers(self):
|
||||||
|
content = "line1\nline2\ndef foo():\n pass\n"
|
||||||
|
result = self.find_closest_lines("def foo():", content)
|
||||||
|
# Should include line numbers in format "N| content"
|
||||||
|
assert "|" in result
|
||||||
|
|
||||||
|
|||||||
@@ -738,12 +738,16 @@ class ShellFileOperations(FileOperations):
|
|||||||
content, old_string, new_string, replace_all
|
content, old_string, new_string, replace_all
|
||||||
)
|
)
|
||||||
|
|
||||||
if error:
|
if error or match_count == 0:
|
||||||
return PatchResult(error=error)
|
err_msg = error or f"Could not find match for old_string in {path}"
|
||||||
|
try:
|
||||||
if match_count == 0:
|
from tools.fuzzy_match import find_closest_lines
|
||||||
return PatchResult(error=f"Could not find match for old_string in {path}")
|
hint = find_closest_lines(old_string, content)
|
||||||
|
if hint:
|
||||||
|
err_msg += "\n\nDid you mean one of these sections?\n" + hint
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return PatchResult(error=err_msg)
|
||||||
# Write back
|
# Write back
|
||||||
write_result = self.write_file(path, new_content)
|
write_result = self.write_file(path, new_content)
|
||||||
if write_result.error:
|
if write_result.error:
|
||||||
|
|||||||
@@ -619,3 +619,65 @@ def _map_normalized_positions(original: str, normalized: str,
|
|||||||
original_matches.append((orig_start, min(orig_end, len(original))))
|
original_matches.append((orig_start, min(orig_end, len(original))))
|
||||||
|
|
||||||
return original_matches
|
return original_matches
|
||||||
|
|
||||||
|
|
||||||
|
def find_closest_lines(old_string: str, content: str, context_lines: int = 2, max_results: int = 3) -> str:
|
||||||
|
"""Find lines in content most similar to old_string for "did you mean?" feedback.
|
||||||
|
|
||||||
|
Returns a formatted string showing the closest matching lines with context,
|
||||||
|
or empty string if no useful match is found.
|
||||||
|
"""
|
||||||
|
if not old_string or not content:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
old_lines = old_string.splitlines()
|
||||||
|
content_lines = content.splitlines()
|
||||||
|
|
||||||
|
if not old_lines or not content_lines:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Use first line of old_string as anchor for search
|
||||||
|
anchor = old_lines[0].strip()
|
||||||
|
if not anchor:
|
||||||
|
# Try second line if first is blank
|
||||||
|
candidates = [l.strip() for l in old_lines if l.strip()]
|
||||||
|
if not candidates:
|
||||||
|
return ""
|
||||||
|
anchor = candidates[0]
|
||||||
|
|
||||||
|
# Score each line in content by similarity to anchor
|
||||||
|
scored = []
|
||||||
|
for i, line in enumerate(content_lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
ratio = SequenceMatcher(None, anchor, stripped).ratio()
|
||||||
|
if ratio > 0.3:
|
||||||
|
scored.append((ratio, i))
|
||||||
|
|
||||||
|
if not scored:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Take top matches
|
||||||
|
scored.sort(key=lambda x: -x[0])
|
||||||
|
top = scored[:max_results]
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
seen_ranges = set()
|
||||||
|
for _, line_idx in top:
|
||||||
|
start = max(0, line_idx - context_lines)
|
||||||
|
end = min(len(content_lines), line_idx + len(old_lines) + context_lines)
|
||||||
|
key = (start, end)
|
||||||
|
if key in seen_ranges:
|
||||||
|
continue
|
||||||
|
seen_ranges.add(key)
|
||||||
|
snippet = "\n".join(
|
||||||
|
f"{start + j + 1:4d}| {content_lines[start + j]}"
|
||||||
|
for j in range(end - start)
|
||||||
|
)
|
||||||
|
parts.append(snippet)
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return "\n---\n".join(parts)
|
||||||
|
|||||||
Reference in New Issue
Block a user