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
20 changes: 20 additions & 0 deletions scripts/introspection_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,20 @@ def _is_recovered_refusal(text: str) -> bool:
_NO_MATCH_RE = re.compile(
r"\b(no match|no results?|nothing found|empty result|0 matches)\b", re.IGNORECASE
)
# Patch-specific sub-categories (#1501). These are checked BEFORE the
# generic _NO_MATCH_RE so specific patch failures aren't swallowed by
# the broad "no match" bucket. They match the structured diagnostic
# text emitted by tools.fuzzy_match.format_structured_error and the
# raw error strings from fuzzy_find_and_replace.
_PATCH_OLD_STRING_EMPTY_RE = re.compile(
r"\bold_string (cannot be empty|is empty)\b", re.IGNORECASE
)
_PATCH_INDENTATION_MISMATCH_RE = re.compile(
r"\bindentation[ _-]mismatch\b|indentation differs\b", re.IGNORECASE
)
_PATCH_AMBIGUOUS_RE = re.compile(
r"\b(found \d+ matches|multiple matches found)\b", re.IGNORECASE
)


def _classify_failure_reason(content: Any) -> Optional[str]:
Expand Down Expand Up @@ -281,6 +295,12 @@ def _classify_failure_reason(content: Any) -> Optional[str]:
return "quota/billing"
if _PARSE_RE.search(haystack):
return "parse-error"
if _PATCH_OLD_STRING_EMPTY_RE.search(haystack):
return "patch-old-string-empty"
if _PATCH_INDENTATION_MISMATCH_RE.search(haystack):
return "patch-indentation-mismatch"
if _PATCH_AMBIGUOUS_RE.search(haystack):
return "patch-ambiguous"
if _NO_MATCH_RE.search(haystack):
return "no-match"
if "exit_code" in data:
Expand Down
37 changes: 37 additions & 0 deletions tests/tools/test_patch_self_correction.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ def test_unknown(self):
def test_none_error(self):
assert classify_error(None, None) == "unknown"

def test_old_string_empty(self):
assert classify_error("old_string cannot be empty", None) == "old_string_empty"

def test_old_string_empty_variant(self):
assert classify_error("old_string is empty", None) == "old_string_empty"


class TestFormatFileContextSnippet:
def test_finds_closest_region(self):
Expand Down Expand Up @@ -190,6 +196,37 @@ def test_escape_drift_includes_error_type_and_recovery(self):
assert "Error type: escape_drift" in result
assert "read_file" in result

def test_old_string_empty_includes_error_type_and_recovery(self):
result = format_structured_error(
"old_string cannot be empty",
0,
"",
"new",
"content\n",
)
assert "Error type: old_string_empty" in result
assert "Recovery:" in result
assert "non-empty old_string" in result

def test_indentation_mismatch_includes_error_type_and_recovery(self):
"""When old_string text matches but indentation differs, the
structured error should classify it as indentation_mismatch (#1501).
"""
content = "def foo():\n return 1\n"
old_string = "def foo():\n\treturn 1" # tab instead of 4 spaces
new_string = "def foo():\n return 42"
result = format_structured_error(
"Could not find a match for old_string in the file",
0,
old_string,
new_string,
content,
file_path="/tmp/test.py",
)
assert "Error type: indentation_mismatch" in result
assert "Recovery:" in result
assert "indentation" in result.lower()

def test_silent_on_permission_error(self):
result = format_structured_error(
"Write denied: protected path",
Expand Down
71 changes: 71 additions & 0 deletions tools/fuzzy_match.py
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,8 @@ def suggest_closest_match(
"ambiguous": "multiple matches found — old_string is not unique",
"identical": "old_string and new_string are identical — no change needed",
"escape_drift": "escape-drift detected — likely tool-call serialization artifact",
"old_string_empty": "old_string is empty — no search target provided",
"indentation_mismatch": "indentation differs between old_string and file content",
"permission": "permission denied or protected path",
"read_failed": "could not read the file",
"write_failed": "could not write changes to the file",
Expand All @@ -1103,6 +1105,8 @@ def classify_error(error: Optional[str], strategy: Optional[str]) -> str:
return "identical"
if "escape-drift" in err or "escape drift" in err:
return "escape_drift"
if "old_string cannot be empty" in err or "old_string is empty" in err:
return "old_string_empty"
if "found" in err and "matches" in err:
return "ambiguous"
if "could not find" in err or "not find a match" in err:
Expand Down Expand Up @@ -1201,6 +1205,53 @@ def format_structured_error(

error_type = classify_error(error, strategy)

# Secondary classification: when the primary classifier returns
# "no_match", inspect the inputs to see if the actual cause is a
# narrower, more actionable sub-category. This shrinks the "other"
# / "unknown" bucket (#1501) by giving the model a precise reason
# and recovery path instead of a generic "not found".
if error_type == "no_match" and old_string and content:
old_lines = old_string.splitlines()
content_lines = content.splitlines()
if old_lines:
# Find the best-matching content line for the first non-blank
# old_string line, then compare indentation across ALL
# corresponding lines (the mismatch is often on a subsequent
# line, not the anchor line itself).
anchor = ""
anchor_old_idx = 0
for oi, ol in enumerate(old_lines):
if ol.strip():
anchor = ol.strip()
anchor_old_idx = oi
break
best_idx = -1
best_ratio = 0.0
for i, cl in enumerate(content_lines):
ratio = SequenceMatcher(None, anchor, cl.strip()).ratio()
if ratio > best_ratio:
best_ratio = ratio
best_idx = i
if best_idx >= 0 and best_ratio >= 0.5:
# Check each old_string line against the corresponding
# content line (offset from best_idx by anchor_old_idx).
for oi in range(anchor_old_idx, len(old_lines)):
ci = best_idx + (oi - anchor_old_idx)
if ci >= len(content_lines):
break
ol = old_lines[oi]
cl = content_lines[ci]
if not ol.strip() or not cl.strip():
continue
# Only compare lines that are textually similar
if SequenceMatcher(None, ol.strip(), cl.strip()).ratio() < 0.5:
continue
old_indent = _leading_whitespace(ol)
content_indent = _leading_whitespace(cl)
if old_indent != content_indent:
error_type = "indentation_mismatch"
break

# Don't add structured context for non-match failures that already
# have clear messages (permission, read/write failures).
if error_type in ("permission", "read_failed", "write_failed", "unknown"):
Expand Down Expand Up @@ -1245,6 +1296,26 @@ def format_structured_error(
"desired state. Proceed with the next step."
)

# For old_string_empty: the model forgot to provide old_string.
if error_type == "old_string_empty":
sections.append(
"Recovery: provide a non-empty old_string that matches the "
"text to replace in the file. Use read_file to see the "
"current content if needed."
)

# For indentation_mismatch: the old_string content matches but the
# whitespace prefix differs — the model needs to copy the file's
# actual indentation.
if error_type == "indentation_mismatch":
sections.append(
"Recovery: the text content matches but the indentation "
"differs. Use read_file to see the exact whitespace (spaces "
"vs tabs, indent level) in the file, then update old_string "
"to match it precisely. Alternatively, use write_file to "
"replace the entire file if the region is hard to anchor."
)

# For no-match with a snippet, add a general recovery hint.
if error_type == "no_match":
snippet = _format_file_context_snippet(content, old_string, context_lines=5)
Expand Down
Loading