Skip to content

fix(tools): keep literal escape sequences when editing a file - #10112

Open
L4XB wants to merge 1 commit into
AstrBotDevs:masterfrom
L4XB:fix/file-edit-literal-escape-sequences
Open

L4XB wants to merge 1 commit into
AstrBotDevs:masterfrom
L4XB:fix/file-edit-literal-escape-sequences

Conversation

@L4XB

@L4XB L4XB commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

astrbot_file_edit_tool runs both of its arguments through _decode_escaped_text unconditionally (fs.py#L614-L615), so a backslash-n in old or new always becomes a real line break.

The other three filesystem tools do not do this. astrbot_file_read_tool returns the file verbatim, astrbot_file_write_tool writes content verbatim, and astrbot_grep_tool passes pattern through untouched — _decode_escaped_text has exactly one caller in the whole repository. So a file the model just wrote with astrbot_file_write_tool can legitimately contain a literal \n, and astrbot_file_edit_tool can then never match it.

Running AstrBot's own _decode_escaped_text over realistic edit arguments:

old / new as the model sends it what the edit tool searches for
print("hello\n") print("hello + newline + ")
pattern = re.compile(r"\number\t(\d+)") pattern = re.compile(r" + newline + umber + tab + (\d+)")
LOG = r"C:\new\temp\report.txt" LOG = r"C: + newline + ew + tab + emp\report.txt"
"\t".join(cols) " + tab + ".join(cols)

None of the four round-trip. Because this is a code editing tool, writing \n or \t literally is the common case, not the exotic one: any Python or JavaScript source with "\n" in a string literal, any regex, any Windows path. The failure is also silent in the worst direction — when only new contains the escape, the replacement succeeds and writes corrupted content into the file.

Modifications / 改动点

astrbot/core/tools/computer_tools/fs.pyFileEditTool.call now tries old/new as they were given and only falls back to the decoded form when the first attempt finds nothing to replace. The decode is kept, so a model that escapes its arguments keeps working exactly as before; it just stops being applied to text that matches the file as written. Both the plain and the descriptor-based edit_file paths go through the same loop.

_decode_escaped_text itself is unchanged.

  • This is NOT a breaking change. / 这不是一个破坏性变更。

该工具原先无条件对 oldnew 调用 _decode_escaped_text,导致源码里的 \n、正则表达式和 Windows 路径都会被破坏;而读取、写入和 grep 三个工具都原样传递字符串。现在先按原样匹配,只有匹配不到时才回退到解码后的形式,因此原有行为仍然保留。

Screenshots or Test Results / 运行截图或测试结果

Four tests added to tests/test_computer_fs_tools.py, covering both the plain and the descriptor-based edit path.

Against the unfixed source (git stash on fs.py only):

FAILED tests/test_computer_fs_tools.py::test_file_edit_matches_source_that_contains_a_literal_escape
  assert 'Replaced 1 occurrence' in 'Error editing file: old string not found in file'
FAILED tests/test_computer_fs_tools.py::test_file_edit_inserts_a_literal_escape_unchanged
  assert 'LOG = "C:\ne...neport.txt"\n' == 'LOG = "C:\\new\\temp\\report.txt"\n'
FAILED tests/test_computer_fs_tools.py::test_restricted_file_edit_matches_a_literal_escape
  assert 'Replaced 1 occurrence' in 'Error editing file: old string not found in file'
3 failed, 1 passed, 38 deselected

The one that passes on both sides is test_file_edit_still_decodes_escapes_when_the_text_does_not_match: it pins the existing escaped-argument behaviour, so the fallback is covered by a test that was green before the change too.

With the fix:

$ python -m pytest tests/test_computer_fs_tools.py tests/test_computer_tool_permissions.py -q
126 passed

$ python -m pytest tests/ -q
2 failed, 3331 passed, 4 skipped

Both remaining failures are tests/test_fastapi_v1_dashboard.py::test_config_update_revokes_only_affected_shell_sessions; they fail the same way on a clean origin/master checkout and are unrelated to this change.

ruff check and ruff format --check are clean on both files.


Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。 (Bug fix, no new feature.)

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”

  • 📚 I checked the affected WebUI instructions and screenshots in docs/zh and docs/en against the changed navigation, page structure, and labels, and updated them in this PR (or explained why no documentation update is needed).
    / 我已对照变化后的 WebUI 入口、页面结构和术语,核对并在本 PR 中更新 docs/zhdocs/en 的相关操作说明与截图(或说明无需更新文档的原因)。 (No WebUI entry point, page or label changes: this only affects how astrbot_file_edit_tool matches its arguments.)

  • 🤓 I have ensured that no new dependencies are introduced.
    / 我确保没有引入新依赖库。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Fix file editing so it matches and writes literal escape sequences correctly without breaking existing escaped-argument support.

Bug Fixes:

  • Preserve literal escape sequences during file edits while retaining support for escaped arguments through a decoding fallback.

Tests:

  • Add coverage for literal escapes, replacement preservation, restricted file editing, and legacy decoded-argument behavior.

`astrbot_file_edit_tool` ran `old` and `new` through
`_decode_escaped_text` unconditionally, so a backslash-n in the arguments
always became a real line break.

The read, write and grep tools pass their strings through unchanged, so a
file written by `astrbot_file_write_tool` can hold a literal "\n" that the
edit tool could then never match: any Python or JavaScript source with
"\n" in a string literal, a regex such as r"\number", or a Windows path
such as C:\new\temp. Inserting such text was corrupted the same way.

Try the arguments as they were given first and fall back to the decoded
form only when that finds nothing to replace, so a model that escapes its
arguments keeps working.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="astrbot/core/tools/computer_tools/fs.py" line_range="618-621" />
<code_context>
+            # unchanged, so a file may legitimately contain a literal "\n".
+            # Use the arguments as they were given and only fall back to
+            # decoding escape sequences when that finds nothing to replace.
+            attempts = [(old, new)]
+            decoded = (_decode_escaped_text(old), _decode_escaped_text(new))
+            if decoded != (old, new):
+                attempts.append(decoded)
             sb = await get_booter(
                 context.context.context,
                 context.context.event.unified_msg_origin,
</code_context>
<issue_to_address>
**issue (bug_risk):** The fallback decodes `old` and `new` as an inseparable pair, so an edit that needs legacy decoding for `old` but contains a literal escape in `new` writes a decoded, corrupted replacement instead of the literal text supplied by the caller. For example, when the file contains an actual newline and `old` is sent as `alpha\nbeta`, a literal `new` value such as `C:\new\temp` is decoded to a newline-containing path during the fallback.

**Triggers:** When the old text requires the legacy escape-decoding fallback but the replacement text is intended to remain literal.

**Suggested fix:** Try the original and decoded forms with an explicit per-argument policy, or provide an unambiguous tool contract rather than decoding both arguments together.

```suggestion
            attempts = [(old, new)]
            decoded_old = _decode_escaped_text(old)
            if decoded_old != old:
                attempts.append((decoded_old, new))
```
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 1 finding to address first, and the edit tool now persists whichever literal or decoded match succeeds first, so an ambiguous or unintended match could write the wrong contents to a file. Reverting the code would not undo files already changed, but the impact is bounded and the file can generally be restored or corrected.

Blocking findings: astrbot/core/tools/computer_tools/fs.py:621


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread astrbot/core/tools/computer_tools/fs.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant