{"repo":"NousResearch/hermes-agent","public_id":"33733","display_label":"Patch tool escape sequence handling in new_string","summary":"These issues and pull requests concern a bug where escape sequences like \\t, \\r, and \\n in the patch tool's new_string field are written as literal characters instead of being interpreted as control bytes, particularly when the escape-normalized matching strategy is used. Multiple fix attempts and related bug reports address unescaping behavior during file writes.","subsystem":"agent","counts":{"members":7,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":33799,"merged_at":"2026-05-28T10:27:21+00:00"},"resolution":{"state":"partially_resolved","by":"pr-33799"},"freshness":{"state":"verified","last_verified_at":"2026-05-28T10:27:21+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2552380952380952},"fix_edges":[{"issue_number":33733,"pr_number":33799,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-28T10:27:21+00:00"}],"members":[{"kind":"issue","number":33733,"title":"patch tool: new_string escape sequences (\\t) get written literally","url":null,"state":"closed"},{"kind":"issue","number":59188,"title":"[Bug]: Escape-normalized fuzzy replacement writes literal \\n into multiline files","url":null,"state":"open"},{"kind":"pr","number":33737,"title":"fix(tools): unescape common sequences in new_string when escape_normalized matches","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#33799","number":33799}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":33745,"title":"fix: unescape \\t and \\r in new_string before writing to file (#33733)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#33737","number":33737},{"kind":"pr","value":"#33733","number":33733}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":33799,"title":"fix(patch): unescape \\t/\\r in new_string when file region uses real control bytes (#33733)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_fuzzy_match.py","tools/fuzzy_match.py"],"fix_hunks":[{"file":"tests/tools/test_fuzzy_match.py","hunks":["@@ -429,3 +429,118 @@ def test_silent_when_no_similar_content(self):\n         )\n         assert result == \"\"\n \n+\n+class TestEscapeNormalizedNewString:\n+    \"\"\"Regression tests for unescaping common sequences in new_string when\n+    the matched region of the file contains real control characters.\n+\n+    Issue #33733: LLMs overwhelmingly represent tabs as the two-character\n+    sequence ``\\\\t`` (backslash + t) in JSON tool-call arguments. When the\n+    file already contains real tab bytes (0x09), writing new_string\n+    verbatim leaves literal ``\\\\t`` characters and corrupts the file.\n+\n+    The fix unescapes ``\\\\t`` -> tab and ``\\\\r`` -> CR in new_string when\n+    the matched file region actually contains those control characters,\n+    regardless of which match strategy fired. ``\\\\n`` is excluded because\n+    newlines serialize correctly through JSON.\n+    \"\"\"\n+\n+    def test_tab_in_new_string_unescaped_under_escape_normalized(self):\n+        \"\"\"File has real tab, model sends literal \\\\t in BOTH old and new.\n+\n+        Match strategy is ``escape_normalized``.\n+        \"\"\"\n+        content = \"def hello():\\n\\tprint(\\\"before\\\")\\n\"\n+        old_string = \"def hello():\\n\\\\tprint(\\\"before\\\")\\n\"\n+        new_string = \"def hello():\\n\\\\tprint(\\\"after\\\")\\n\"\n+        new, count, strategy, err = fuzzy_find_and_replace(content, old_string, new_string)\n+        assert err is None, f\"Unexpected error: {err}\"\n+        assert count == 1\n+        assert strategy == \"escape_normalized\"\n+        assert \"\\tprint(\\\"after\\\")\" in new\n+        assert \"\\\\t\" not in new\n+\n+    def test_tab_in_new_string_unescaped_under_exact(self):\n+        \"\"\"File has real tab, old_string has real tab too (matches via\n+        ``exact``), but new_string still arrives with literal ``\\\\t``.\n+\n+        This is the issue's headline reproduction — the previous fix that\n+        gated on ``strategy_name == \"escape_normalized\"`` missed this case.\n+        \"\"\"\n+        content = \"def hello():\\n\\tprint(\\\"before\\\")\\n\"\n+        old_string = \"\\tprint(\\\"before\\\")\"           # real tab\n+        new_string = \"\\\\tprint(\\\"after\\\")\"           # literal backslash + t\n+        new, count, strategy, err = fuzzy_find_and_replace(content, old_string, new_string)\n+        assert err is None, f\"Unexpected error: {err}\"\n+        assert count == 1\n+        assert strategy == \"exact\"\n+        assert \"\\tprint(\\\"after\\\")\" in new\n+        assert \"\\\\t\" not in new\n+\n+    def test_carriage_return_in_new_string_unescaped(self):\n+        \"\"\"File has real CR, model sends literal \\\\r in new_string.\"\"\"\n+        content = \"line1\\r\\nline2\\r\\n\"\n+        old_string = \"line1\\\\r\\\\nline2\\\\r\\\\n\"\n+        new_string = \"replaced\\\\r\\\\n\"\n+        new, count, strategy, err = fuzzy_find_and_replace(content, old_string, new_string)\n+        assert err is None, f\"Unexpected error: {err}\"\n+        assert count == 1\n+        assert strategy == \"escape_normalized\"\n+        assert \"replaced\\r\" in new\n+\n+    def test_newline_in_new_string_NOT_unescaped(self):\n+        \"\"\"``\\\\n`` is intentionally left alone — newlines serialize correctly\n+        through JSON, and unescaping would corrupt source-code escape\n+        sequences far more often than help.\n+        \"\"\"\n+        content = \"line1\\nline2\\n\"\n+        old_string = \"line1\\nline2\"\n+        new_string = \"alpha\\\\nbeta\"                 # literal backslash + n\n+        new, count, _, err = fuzzy_find_and_replace(content, old_string, new_string)\n+        assert err is None, f\"Unexpected error: {err}\"\n+        assert count == 1\n+        # The literal two-character sequence ``\\n`` must survive verbatim.\n+        assert \"alpha\\\\nbeta\" in new\n+        # And there should be no real newline added where ``\\\\n`` sat.\n+        assert \"alpha\\nbeta\" not in new\n+\n+    def test_mixed_tab_and_newline_only_tab_unescaped(self):\n+        \"\"\"When new_string contains both \\\\t and \\\\n, only \\\\t is converted.\"\"\"\n+        content = \"def foo():\\n\\tpass\\n\"\n+        old_string = \"def foo():\\n\\tpass\\n\"\n+        new_string = \"def bar():\\\\n\\\\treturn 1\\\\n\"\n+        new, count, _, err = fuzzy_find_and_replace(content, old_string, new_string)\n+        assert err is None, f\"Unexpected error: {err}\"\n+        assert count == 1\n+        # \\t -> real tab\n+        assert \"\\treturn 1\" in new\n+        assert \"\\\\t\" not in new\n+        # \\n preserved as literal backslash-n\n+        assert \"\\\\n\" in new\n+\n+    def test_exact_match_preserves_literal_backslash_t_in_string_literal(self):\n+        \"\"\"If the matched region of the file does NOT contain a real tab,\n+        new_string's literal ``\\\\t`` is preserved — the file genuinely uses\n+        a backslash-t sequence (e.g. a Python source line ``sep = \"\\\\t\"``).\n+        \"\"\"\n+        content = 'sep = \"\\\\t\"\\n'                   # source contains backslash + t\n+        old_string = 'sep = \"\\\\t\"\\n'\n+        new_string = 'sep = \"\\\\tab\"\\n'              # still backslash + t literal\n+        new, count, strategy, err = fuzzy_find_and_replace(content, old_string, new_string)\n+        assert err is None, f\"Unexpected error: {err}\"\n+        assert count == 1\n+        assert strategy == \"exact\"\n+        # File still has the literal two-char ``\\t`` — no tab byte injected.\n+        assert 'sep = \"\\\\tab\"' in new\n+        assert \"\\t\" not in new\n+\n+    def test_no_escape_sequences_passthrough(self):\n+        \"\"\"When new_string has no \\\\t or \\\\r, the helper is a no-op.\"\"\"\n+        content = \"def foo():\\n    return 1\\n\"\n+        old_string = \"def foo():\\n    return 1\\n\"\n+        new_string = \"def foo():\\n    return 2\\n\"\n+        new, count, _, err = fuzzy_find_and_replace(content, old_string, new_string)\n+        assert err is None\n+        assert count == 1\n+        assert \"return 2\" in new\n+"]},{"file":"tools/fuzzy_match.py","hunks":["@@ -113,8 +113,29 @@ def fuzzy_find_and_replace(content: str, old_string: str, new_string: str,\n             # old_string/new_string — e.g. LLM used 2-space indent but the\n             # file is 4-space. Shift new_string by the indentation delta so\n             # the replacement matches the file's actual indent pattern.\n+            # LLMs frequently serialize tabs / carriage returns in JSON\n+            # tool-call arguments as the two-character sequences ``\\t`` and\n+            # ``\\r`` (backslash + letter) instead of the real control bytes.\n+            # If we write new_string verbatim, the file ends up with literal\n+            # backslash sequences where the surrounding code uses real tabs.\n+            #\n+            # Strategy: only unescape when the matched region of the file\n+            # *actually contains* the corresponding real control character.\n+            # That mirrors the region-based heuristic in\n+            # ``_detect_escape_drift`` and keeps legitimate writes of the\n+            # literal two-character string ``\"\\t\"`` (e.g. patching Python\n+            # source that contains a tab string literal in source text)\n+            # untouched — those files have a backslash+t in the matched\n+            # region, not a real tab, so we leave new_string alone.\n+            #\n+            # ``\\n`` is intentionally excluded: newlines serialize correctly\n+            # through JSON, and rewriting backslash-n would mangle escape\n+            # sequences in source code constants far more often than help.\n+            effective_new = _maybe_unescape_new_string(\n+                new_string, content, matches,\n+            )\n             new_content = _apply_replacements(\n-                content, matches, new_string,\n+                content, matches, effective_new,\n                 old_string=old_string if strategy_name != \"exact\" else None,\n             )\n             return new_content, len(matches), strategy_name, None","@@ -247,6 +268,42 @@ def _reindent_replacement(file_region: str, old_string: str, new_string: str) ->\n     return \"\\n\".join(out_lines)\n \n \n+def _maybe_unescape_new_string(new_string: str,\n+                               content: str,\n+                               matches: List[Tuple[int, int]]) -> str:\n+    \"\"\"Conditionally unescape ``\\\\t``/``\\\\r`` in new_string.\n+\n+    LLMs frequently send the two-character sequences ``\\\\t`` (backslash + t)\n+    and ``\\\\r`` (backslash + r) inside JSON tool-call arguments where they\n+    meant a real tab or carriage-return byte. Writing the string verbatim\n+    corrupts tab-indented files with literal backslash-letter pairs.\n+\n+    The unescape is only applied per-sequence when the *matched region of\n+    the file* actually contains the corresponding control character — that\n+    is, we only convert ``\\\\t`` -> tab when the file region we're replacing\n+    contains a real tab byte. Files that legitimately contain the literal\n+    two-character string ``\"\\\\t\"`` (e.g. a Python source line that defines\n+    ``sep = \"\\\\t\"``) get a backslash+t in the matched region instead of a\n+    tab, so we leave new_string alone.\n+\n+    ``\\\\n`` is intentionally excluded: newlines serialize correctly through\n+    JSON and rewriting backslash-n would corrupt escape sequences in\n+    string literals far more often than it would help.\n+    \"\"\"\n+    # Cheap pre-check — bail out unless new_string actually contains one of\n+    # the suspect sequences. Keeps the common case free.\n+    if \"\\\\t\" not in new_string and \"\\\\r\" not in new_string:\n+        return new_string\n+\n+    matched_regions = \"\".join(content[start:end] for start, end in matches)\n+    out = new_string\n+    if \"\\\\t\" in out and \"\\t\" in matched_regions:\n+        out = out.replace(\"\\\\t\", \"\\t\")\n+    if \"\\\\r\" in out and \"\\r\" in matched_regions:\n+        out = out.replace(\"\\\\r\", \"\\r\")\n+    return out\n+\n+\n def _apply_replacements(content: str, matches: List[Tuple[int, int]],\n                         new_string: str, old_string: Optional[str] = None) -> str:\n     \"\"\"\n"]}]},{"kind":"pr","number":59192,"title":"fix(patch): normalize newline escapes in fuzzy replacements","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#40763","number":40763},{"kind":"pr","value":"#55315","number":55315},{"kind":"pr","value":"#56408","number":56408},{"kind":"pr","value":"#59188","number":59188},{"kind":"pr","value":"#55314","number":55314}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":59569,"title":"fix(tools): unescape \\\\n in replacement when escape_normalized strategy matched (#59188)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#59192","number":59192}],"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}