feat(tools): name the dead end — past-EOF and empty-file notes in read_file
A read past EOF returned content '900|' (a phantom line-number prefix that looks like a real line) and an empty file returned '1|' — both ambiguous silence: indistinguishable, from inside the model, from a broken tool, so it re-reads and widens windows. Name the dead end and its recovery instead: 'offset 900 is beyond the end of the file (412 lines total). Retry with offset <= 412.' / 'File is empty (0 bytes).' Notes, not errors — a fact about the file is not a failure. Boundary pinned by test: offset == total_lines still reads (an off-by-one in a resume hint is a silently corrupted read). Measured (file-only arm, 3 reps, control vs feature): qwen3.8-max -18% tokens, -26% tool calls, -17% turns across the two affected tasks; opus-4.8 flat (within rep noise); accuracy held 1.00.
This commit is contained in:
parent
758524fa5f
commit
471efcc2ca
|
|
@ -0,0 +1,44 @@
|
|||
"""Tests for the past-EOF and empty-file notes in read_file.
|
||||
|
||||
An empty content string is ambiguous from inside the model (empty file?
|
||||
bad offset? broken tool?) — the tool names the dead end and the recovery.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from tools.file_tools import read_file_tool
|
||||
|
||||
|
||||
class TestPastEofNote:
|
||||
def test_offset_beyond_eof_names_recovery(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
p = tmp_path / "r.txt"
|
||||
p.write_text("\n".join(f"l{i}" for i in range(1, 51)) + "\n")
|
||||
result = json.loads(read_file_tool(str(p), offset=900, limit=50))
|
||||
hint = result.get("hint") or ""
|
||||
assert "beyond the end" in hint
|
||||
assert "50" in hint # states actual line count
|
||||
assert not result.get("error"), "a fact about the file is not an error"
|
||||
|
||||
def test_offset_at_last_line_still_reads(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
p = tmp_path / "r.txt"
|
||||
p.write_text("a\nb\nc\n")
|
||||
result = json.loads(read_file_tool(str(p), offset=3, limit=10))
|
||||
assert "c" in result.get("content", "")
|
||||
assert "beyond" not in (result.get("hint") or "")
|
||||
|
||||
def test_empty_file_says_so(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
p = tmp_path / "e.txt"
|
||||
p.write_text("")
|
||||
result = json.loads(read_file_tool(str(p)))
|
||||
assert "empty" in (result.get("hint") or "").lower()
|
||||
assert not result.get("error")
|
||||
|
||||
def test_normal_pagination_hint_unchanged(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
p = tmp_path / "r.txt"
|
||||
p.write_text("\n".join(f"l{i}" for i in range(1, 300)) + "\n")
|
||||
result = json.loads(read_file_tool(str(p), offset=1, limit=100))
|
||||
assert "offset=101" in (result.get("hint") or "")
|
||||
|
|
@ -1331,7 +1331,30 @@ class ShellFileOperations(FileOperations):
|
|||
hint = None
|
||||
if truncated:
|
||||
hint = f"Use offset={end_line + 1} to continue reading (showing {offset}-{end_line} of {total_lines} lines)"
|
||||
|
||||
|
||||
# Ambiguous-silence guards: an empty content string is
|
||||
# indistinguishable, from inside the model, from a broken tool —
|
||||
# it re-reads, widens the window, tries another path. Name the
|
||||
# dead end and its recovery instead.
|
||||
if file_size == 0:
|
||||
return ReadResult(
|
||||
content="",
|
||||
total_lines=0,
|
||||
file_size=0,
|
||||
hint="File is empty (0 bytes).",
|
||||
)
|
||||
if offset > total_lines > 0:
|
||||
return ReadResult(
|
||||
content="",
|
||||
total_lines=total_lines,
|
||||
file_size=file_size,
|
||||
hint=(
|
||||
f"Note: offset {offset} is beyond the end of the file "
|
||||
f"({total_lines} lines total). Retry with offset <= "
|
||||
f"{total_lines}."
|
||||
),
|
||||
)
|
||||
|
||||
return ReadResult(
|
||||
content=self._add_line_numbers(read_output, offset),
|
||||
total_lines=total_lines,
|
||||
|
|
|
|||
Loading…
Reference in New Issue