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
14 changes: 13 additions & 1 deletion src/nano/edit_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,10 +230,22 @@ def _nano_edit_call(tool_input: dict[str, Any], context: ToolContext) -> ToolRes
raise ToolInputError(
f"file is too large ({size} bytes, max {_MAX_FILE_SIZE})"
)
if not context.was_file_read_and_unchanged(path):
_read_status = context.file_read_status(path)
if _read_status == "not_read":
raise ToolInputError(
"file must be read first and be unchanged since last read"
)
# "modified" — read before, but the file changed on disk since
# (commonly the agent's own script or a build touched it; 7 such
# blocks cost full round-trips in the first TB 2.1 nano run).
# Soft-refresh instead of demanding a ceremonial re-Read: proceed
# against the CURRENT bytes and let the ladder arbitrate — every
# old_string must still match uniquely in the current content, so an
# external change that touched the target region still fails with
# the actionable not-found/not-unique error, while changes elsewhere
# in the file no longer block the edit. The stock tool (and the
# blind-edit protection for never-read files) is unchanged; pi has
# no gate at all.

# Bytes, not read_text: Python text mode does universal-newline
# translation, which would silently erase CRLF before
Expand Down
35 changes: 35 additions & 0 deletions tests/nano/test_nano_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,41 @@ def test_tool_requires_read_first(tool_context, tmp_path):
)


def test_soft_refresh_when_file_changed_elsewhere(tool_context, tmp_path):
# Read → the agent's own script rewrites ANOTHER part of the file →
# Edit of an untouched region proceeds without a ceremonial re-Read.
import os
p = _write_and_read(tool_context, tmp_path, "s.py", "alpha\nbeta\ngamma\n")
p.write_text("ALPHA\nbeta\ngamma\n")
os.utime(p, (1, 1)) # force an mtime change even on coarse clocks
result = NanoEditTool.call(
{"file_path": str(p), "old_string": "gamma", "new_string": "GAMMA"},
tool_context,
)
assert p.read_text() == "ALPHA\nbeta\nGAMMA\n"
assert result.output["type"] == "update"
# Fingerprint refreshed: an immediate follow-up edit passes the gate.
NanoEditTool.call(
{"file_path": str(p), "old_string": "beta", "new_string": "BETA"},
tool_context,
)
assert p.read_text() == "ALPHA\nBETA\nGAMMA\n"


def test_soft_refresh_still_blocks_changed_target(tool_context, tmp_path):
# The externally-changed region IS the edit target → the ladder's
# actionable not-found error, never a blind overwrite.
import os
p = _write_and_read(tool_context, tmp_path, "t.py", "alpha\nbeta\n")
p.write_text("ALPHA\nbeta\n")
os.utime(p, (1, 1))
with pytest.raises(ToolInputError, match="must match exactly"):
NanoEditTool.call(
{"file_path": str(p), "old_string": "alpha", "new_string": "omega"},
tool_context,
)


def test_tool_crlf_file_edited_with_lf_oldstring(tool_context, tmp_path):
p = _write_and_read(
tool_context, tmp_path, "w.txt", "line one\r\nline two\r\n"
Expand Down
Loading