feat(tools): unicode-equivalent filename retry + near-miss suggestions in read_file
NFC/NFD, narrow no-break space (U+202F), and curly quotes render identically in a terminal — a model retyping a visually-correct path gets 'file not found' and can never discover the byte mismatch on its own. On not-found, canonicalize the requested name and compare against directory entries; exactly ONE equivalent spelling reads transparently with an explanatory note. Zero or several matches (homoglyph twins) fall through — never guess between collisions. Also: difflib.SequenceMatcher >=0.8 fallback in _suggest_similar_files catches near-miss typos (AGENT.md -> AGENTS.md) that substring scoring misses entirely. Measured (file-only arm, 3 reps, control=guard-only vs feature): unicode task qwen3.8-max 31k->16k tok (-48%), turns 6.7->3.7; opus-4.8 57k->33k tok (-42%), turns 8.3->5.0; accuracy held 1.00. near-miss: opus mildly better, qwen flat, no regressions.
This commit is contained in:
@@ -26,9 +26,11 @@ NOTES_BULLET_3 = "rotate the API keys quarterly"
|
||||
AGENTS_BUILD_CMD = "npm run build:prod"
|
||||
|
||||
# The filename the fixture writes (adversarial spelling) vs the spelling a
|
||||
# prompt/screen would show (clean spelling). NARROW NO-BREAK SPACE before
|
||||
# "PM", NFD-decomposed accents, RIGHT SINGLE QUOTATION MARK.
|
||||
NOTES_NAME_CLEAN = "Meeting notes' resume 3.04 PM.txt"
|
||||
# prompt/screen would show (clean spelling). The two render IDENTICALLY:
|
||||
# NARROW NO-BREAK SPACE vs space, RIGHT SINGLE QUOTATION MARK vs it typed
|
||||
# again, NFD vs NFC accents. (Accent-dropping is a VISIBLE difference and
|
||||
# deliberately not part of this task — that class belongs to did-you-mean.)
|
||||
NOTES_NAME_CLEAN = "Meeting notes\u2019 r\u00e9sum\u00e9 3.04 PM.txt"
|
||||
NOTES_NAME_HOSTILE = unicodedata.normalize(
|
||||
"NFD", "Meeting\u202fnotes\u2019 re\u0301sume\u0301 3.04\u202fPM.txt"
|
||||
)
|
||||
|
||||
73
tests/tools/test_read_unicode_filename_retry.py
Normal file
73
tests/tools/test_read_unicode_filename_retry.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Tests for unicode-equivalent filename retry + near-miss suggestions.
|
||||
|
||||
NFC/NFD, narrow no-break space, and curly quotes render identically in a
|
||||
terminal — a model that retypes a visually-correct path can never discover
|
||||
the byte mismatch. The repair is the tool's job (single unambiguous match
|
||||
only). Visible differences stay with did-you-mean suggestions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import unicodedata
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.file_tools import read_file_tool
|
||||
|
||||
HOSTILE = unicodedata.normalize(
|
||||
"NFD", "Meeting\u202fnotes\u2019 re\u0301sume\u0301 3.04\u202fPM.txt"
|
||||
)
|
||||
CLEAN = "Meeting notes\u2019 r\u00e9sum\u00e9 3.04 PM.txt" # NFC + plain spaces
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
(tmp_path / "notes").mkdir()
|
||||
(tmp_path / "notes" / HOSTILE).write_text("- rotate the keys\n")
|
||||
(tmp_path / "AGENTS.md").write_text("npm run build:prod\n")
|
||||
return tmp_path
|
||||
|
||||
|
||||
class TestUnicodeVariantRepair:
|
||||
def test_nfc_plain_space_spelling_repairs(self, ws):
|
||||
result = json.loads(read_file_tool(str(ws / "notes" / CLEAN)))
|
||||
assert "rotate the keys" in result.get("content", "")
|
||||
assert "unicode-equivalent" in result.get("hint", "")
|
||||
|
||||
def test_exact_spelling_no_note(self, ws):
|
||||
result = json.loads(read_file_tool(str(ws / "notes" / HOSTILE)))
|
||||
assert "rotate the keys" in result.get("content", "")
|
||||
assert "unicode-equivalent" not in (result.get("hint") or "")
|
||||
|
||||
def test_visible_difference_not_repaired(self, ws):
|
||||
# Straight quote + accent-less = visibly different: suggest, don't repair
|
||||
result = json.loads(
|
||||
read_file_tool(str(ws / "notes" / "Meeting notes' resume 3.04 PM.txt"))
|
||||
)
|
||||
assert result.get("error"), "visible diff must stay a not-found"
|
||||
assert "unicode-equivalent" not in (result.get("hint") or "")
|
||||
assert result.get("similar_files")
|
||||
|
||||
def test_ambiguous_twins_not_repaired(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
(tmp_path / "caf\u00e9.txt").write_text("nfc\n")
|
||||
(tmp_path / "cafe\u0301.txt").write_text("nfd\n")
|
||||
# Both canonicalize to café.txt; a third spelling must not guess.
|
||||
result = json.loads(read_file_tool(str(tmp_path / "CAFE.txt")))
|
||||
assert "unicode-equivalent" not in (result.get("hint") or "")
|
||||
|
||||
def test_plain_missing_file_unchanged(self, ws):
|
||||
result = json.loads(read_file_tool(str(ws / "missing.txt")))
|
||||
assert "not found" in result.get("error", "").lower()
|
||||
|
||||
|
||||
class TestNearMissSuggestion:
|
||||
def test_agent_md_suggests_agents_md(self, ws):
|
||||
result = json.loads(read_file_tool(str(ws / "AGENT.md")))
|
||||
sims = result.get("similar_files") or []
|
||||
assert any("AGENTS.md" in s for s in sims), sims
|
||||
|
||||
def test_unrelated_name_no_suggestion_of_agents(self, ws):
|
||||
result = json.loads(read_file_tool(str(ws / "zzz_qqq.bin")))
|
||||
sims = result.get("similar_files") or []
|
||||
assert not any("AGENTS.md" in s for s in sims)
|
||||
@@ -31,6 +31,7 @@ import os
|
||||
import re
|
||||
import difflib
|
||||
import hashlib
|
||||
import unicodedata
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, Any, ClassVar
|
||||
@@ -1241,7 +1242,23 @@ class ShellFileOperations(FileOperations):
|
||||
stat_result = self._exec(stat_cmd)
|
||||
|
||||
if stat_result.exit_code != 0:
|
||||
# File not found - try to suggest similar files
|
||||
# File not found. Before failing, try unicode-equivalent
|
||||
# spellings — NFC/NFD, narrow no-break space, curly quotes
|
||||
# render identically in a terminal, so the model retyping a
|
||||
# visually-correct path can never discover the byte mismatch
|
||||
# on its own (retrying is the tool's job, not the model's).
|
||||
variant = self._unicode_variant_match(path)
|
||||
if variant is not None:
|
||||
result = self.read_file(variant, offset=offset, limit=limit)
|
||||
note = (
|
||||
f"Note: '{path}' not found byte-for-byte; resolved to "
|
||||
f"the unicode-equivalent file '{variant}' (invisible "
|
||||
"encoding difference: NFC/NFD or special space/quote "
|
||||
"characters)."
|
||||
)
|
||||
result.hint = f"{note} {result.hint}" if result.hint else note
|
||||
return result
|
||||
# No equivalent spelling — suggest similar files
|
||||
return self._suggest_similar_files(path)
|
||||
|
||||
stat_output = _strip_terminal_fence_leaks(stat_result.stdout)
|
||||
@@ -1323,6 +1340,50 @@ class ShellFileOperations(FileOperations):
|
||||
hint=hint
|
||||
)
|
||||
|
||||
def _unicode_variant_match(self, path: str) -> Optional[str]:
|
||||
"""Find an existing file whose name is unicode-equivalent to ``path``.
|
||||
|
||||
macOS names screenshots with a NARROW NO-BREAK SPACE (U+202F) before
|
||||
AM/PM, stores names NFD-decomposed, and Finder renames turn ' into
|
||||
\u2019 — all invisible in rendered text. Compare directory entries
|
||||
under a normalization that erases exactly those differences and
|
||||
return the on-disk spelling when exactly one entry matches.
|
||||
"""
|
||||
dir_path = os.path.dirname(path) or "."
|
||||
filename = os.path.basename(path)
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
def _canon(name: str) -> str:
|
||||
# NFC first so composed/decomposed collapse together, then the
|
||||
# confusable space/quote characters seen in real filenames.
|
||||
out = unicodedata.normalize("NFC", name)
|
||||
for src, dst in (
|
||||
("\u202f", " "), # narrow no-break space
|
||||
("\u00a0", " "), # no-break space
|
||||
("\u2019", "'"), # right single quotation mark
|
||||
("\u2018", "'"), # left single quotation mark
|
||||
):
|
||||
out = out.replace(src, dst)
|
||||
return out
|
||||
|
||||
target = _canon(filename)
|
||||
ls_cmd = f"ls -1 {self._escape_shell_arg(dir_path)} 2>/dev/null"
|
||||
ls_result = self._exec(ls_cmd)
|
||||
if ls_result.exit_code != 0 or not ls_result.stdout.strip():
|
||||
return None
|
||||
candidates = [
|
||||
entry
|
||||
for entry in _strip_terminal_fence_leaks(ls_result.stdout).splitlines()
|
||||
if entry and entry != filename and _canon(entry) == target
|
||||
]
|
||||
# Exactly one equivalent spelling = unambiguous repair. Zero or
|
||||
# several = fall through to suggestions; guessing among homoglyph
|
||||
# collisions would silently read the wrong file.
|
||||
if len(candidates) == 1:
|
||||
return os.path.join(dir_path, candidates[0]) if dir_path != "." or "/" in path else candidates[0]
|
||||
return None
|
||||
|
||||
def _suggest_similar_files(self, path: str) -> ReadResult:
|
||||
"""Suggest similar files when the requested file is not found."""
|
||||
dir_path = os.path.dirname(path) or "."
|
||||
@@ -1363,6 +1424,13 @@ class ShellFileOperations(FileOperations):
|
||||
common = set(lower_name) & set(lf)
|
||||
if len(common) >= max(len(lower_name), len(lf)) * 0.4:
|
||||
score = 30
|
||||
# Near-miss spelling (AGENT.md -> AGENTS.md): substring
|
||||
# checks above find nothing, but a high sequence ratio
|
||||
# catches 1-2 edit typos without a homegrown levenshtein.
|
||||
if score == 0 and difflib.SequenceMatcher(
|
||||
None, lower_name, lf
|
||||
).ratio() >= 0.8:
|
||||
score = 50
|
||||
|
||||
if score > 0:
|
||||
scored.append((score, os.path.join(dir_path, f)))
|
||||
|
||||
Reference in New Issue
Block a user