From 48cb875a182d8727fc1e887a7fd2b985b330214f Mon Sep 17 00:00:00 2001 From: T0mSIlver Date: Sat, 27 Jun 2026 01:02:48 +0000 Subject: [PATCH] fix(read): support negative offsets counting from end of file The FastContext paper (Appendix E, p. 19) specifies that Read's offset 'Negative values count backwards from the end of the file', but the implementation rejected any offset <= 0 and the schema description dropped the sentence. Resolve negative offsets to a 1-indexed line from the end (clamping over-long negatives to the start), restore the documented behavior in the schema, and add a regression test. --- src/fastcontext/agent/tool/read.py | 11 ++++++--- tests/test_read_negative_offset.py | 37 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 tests/test_read_negative_offset.py diff --git a/src/fastcontext/agent/tool/read.py b/src/fastcontext/agent/tool/read.py index a0caa76..fa32e26 100644 --- a/src/fastcontext/agent/tool/read.py +++ b/src/fastcontext/agent/tool/read.py @@ -21,7 +21,7 @@ class ReadTool(Tool): }, "offset": { "type": "integer", - "description": "The line number to start reading from. Positive values are 1-indexed from the start of the file. Only provide if the file is too large to read at once.", + "description": "The line number to start reading from. Positive values are 1-indexed from the start of the file. Negative values count backwards from the end of the file (e.g. -1 starts at the last line). Only provide if the file is too large to read at once.", }, "limit": { "type": "integer", @@ -47,8 +47,8 @@ async def call(self, parameters: str, **kwargs) -> str: if not Path(file_path).exists(): return f"Error: {file_path} does not exist" - if not isinstance(offset, int) or offset <= 0: - return "Error: offset must be a positive integer" + if not isinstance(offset, int) or isinstance(offset, bool) or offset == 0: + return "Error: offset must be a non-zero integer" if limit is not None and (not isinstance(limit, int) or limit <= 0): return "Error: limit must be a positive integer" @@ -59,6 +59,11 @@ async def call(self, parameters: str, **kwargs) -> str: if len(raw_lines) == 0: return "File is empty." + # Negative offsets count backwards from the end of the file; clamp to the + # start so an over-long negative offset still reads from line 1. + if offset < 0: + offset = max(1, len(raw_lines) + offset + 1) + end_line = -1 if limit is not None: end_line = offset + limit - 1 diff --git a/tests/test_read_negative_offset.py b/tests/test_read_negative_offset.py new file mode 100644 index 0000000..c101d25 --- /dev/null +++ b/tests/test_read_negative_offset.py @@ -0,0 +1,37 @@ +import asyncio +import json +import tempfile +from pathlib import Path + +from fastcontext.agent.tool.read import ReadTool + + +def _read(read, cwd, params): + return asyncio.run(read.call(json.dumps(params), cwd=cwd)) + + +def test_read_negative_offset_counts_from_end(): + """A negative offset must count backwards from the end of the file + (FastContext paper, Appendix E, p. 19: 'Negative values count backwards + from the end').""" + read = ReadTool() + with tempfile.TemporaryDirectory() as cwd: + p = Path(cwd) / "f.txt" + p.write_text("".join(f"line{i}\n" for i in range(1, 11)), encoding="utf-8") # 10 lines + + # -2 -> start at line 9, read to end. + out = _read(read, cwd, {"path": str(p), "offset": -2}) + assert "9|line9" in out and "10|line10" in out + assert "8|line8" not in out + + # -1 with limit 1 -> only the last line. + out = _read(read, cwd, {"path": str(p), "offset": -1, "limit": 1}) + assert "10|line10" in out and "9|line9" not in out + + # Over-long negative offset clamps to the start of the file. + out = _read(read, cwd, {"path": str(p), "offset": -999}) + assert "1|line1" in out + + # Zero is still rejected. + out = _read(read, cwd, {"path": str(p), "offset": 0}) + assert "Error" in out and "non-zero" in out