From 480ecac8856b12660ab3aef0785faae6fed39b1a Mon Sep 17 00:00:00 2001 From: agentforce314 <273884145+agentforce314@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:26:48 -0700 Subject: [PATCH] =?UTF-8?q?feat(nano):=20staleness-gate=20soft=20refresh?= =?UTF-8?q?=20=E2=80=94=20re-anchor=20on=20current=20bytes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first TB 2.1 nano run hit 'file must be read first and be unchanged' 7 times (tune-mjcf 2x, build-cython-ext 3x, ...), each a full model round-trip on a wall-clocked task, always in the same shape: the agent's own script or build touched the file after the last Read. Nano's Edit now discriminates via file_read_status(): never-read keeps the hard error (blind-edit protection intact), but read-then-modified proceeds against the CURRENT bytes and lets 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 error, while changes elsewhere no longer cost a ceremonial re-Read. Fingerprint refreshes on success. Stock Edit unchanged; pi has no gate at all. Tests: changed-elsewhere proceeds + follow-up edit passes the refreshed gate; changed-target still blocked; never-read still blocked. 63 pass. Co-Authored-By: Claude Fable 5 --- src/nano/edit_tool.py | 14 +++++++++++++- tests/nano/test_nano_edit.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/nano/edit_tool.py b/src/nano/edit_tool.py index 047698d6..446a9b29 100644 --- a/src/nano/edit_tool.py +++ b/src/nano/edit_tool.py @@ -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 diff --git a/tests/nano/test_nano_edit.py b/tests/nano/test_nano_edit.py index f08d77c0..bd914fb0 100644 --- a/tests/nano/test_nano_edit.py +++ b/tests/nano/test_nano_edit.py @@ -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"