diff --git a/src/plyngent/tools/file/get_truncated.py b/src/plyngent/tools/file/get_truncated.py index 0e10b1d..dfb5da8 100644 --- a/src/plyngent/tools/file/get_truncated.py +++ b/src/plyngent/tools/file/get_truncated.py @@ -4,7 +4,7 @@ from __future__ import annotations from plyngent.agent import ToolTag, tool from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS -from plyngent.tools.file.read import read_file +from plyngent.tools.file.read import line_range_label, read_raw_text from plyngent.tools.net.fetch import fetch from plyngent.tools.truncate_token import decode_truncate_token, truncate_with_token @@ -16,16 +16,17 @@ async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_ Pass the token shown at the end of a truncated ``read_file``, ``fetch``, or earlier ``get_truncated`` result to keep reading without re-requesting the whole source or raising limits. Truncated output carries a fresh token, so - chunks chain indefinitely. + chunks chain indefinitely. File chunks start with a 1-based ``L{begin}-{end}`` + line range like ``read_file``. """ parsed = decode_truncate_token(token) if parsed is None: return "error: invalid truncate token" if parsed.kind == "http": return await fetch.handler(parsed.location, offset=parsed.offset, max_chars=parsed.limit) - text = await read_file.handler(parsed.location) - if text.startswith("error:"): - return text + text = read_raw_text(parsed.location) + if text is None: + return f"error: not a file: {parsed.location}" start = parsed.offset segment = text[start : start + max_chars] chunk, _ = truncate_with_token( @@ -37,4 +38,6 @@ async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_ limit=max_chars, total_len=len(text), ) - return chunk + content_len = len(chunk.split("\n...[", 1)[0]) if "\n...[" in chunk else len(chunk) + header = line_range_label(text, start, start + content_len) + return f"{header}\n{chunk}" diff --git a/src/plyngent/tools/file/read.py b/src/plyngent/tools/file/read.py index 42055c8..e6fc6cc 100644 --- a/src/plyngent/tools/file/read.py +++ b/src/plyngent/tools/file/read.py @@ -18,6 +18,31 @@ def _format_with_lineno(lines: list[str], *, start_lineno: int) -> str: return "".join(out) +def line_range_label(text: str, start_char: int, end_char: int) -> str: + """1-based inclusive line range covering ``text[start_char:end_char]``. + + Char offsets do not map 1:1 to lines; this converts a contiguous char range + into the 1-based file line numbers it spans (offset is 0-based, so line 1 + starts at char 0). + """ + begin = text.count("\n", 0, start_char) + 1 + last = max(start_char, end_char - 1) + end = text.count("\n", 0, last) + 1 + return f"L{begin}-{end}" + + +def read_raw_text(path: str) -> str | None: + """Full raw text of a workspace file; None when the path is not a regular file. + + Shared by ``read_file`` and ``get_truncated`` so truncate-token char offsets + always refer to the raw file text (never the ``L{begin}-{end}`` header). + """ + target = resolve_path(path) + if not target.is_file(): + return None + return target.read_text(encoding="utf-8", errors="replace") + + @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) async def read_file( path: str, @@ -29,18 +54,21 @@ async def read_file( ) -> str: """Read a text file under the workspace. - ``offset`` is 0-based line start; ``limit`` is max lines (None = rest of file). - When ``with_lineno`` is true, each line is prefixed with its 1-based file line - number (`` N|…``), matching ``edit_lineno`` numbering, and those lines are - marked readable for ``edit_lineno`` this turn. + ``offset`` is 0-based line start (0 = first line); ``limit`` is max lines + (None = rest of file). When ``with_lineno`` is true, each line is prefixed + with its 1-based file line number (`` N|…``), matching ``edit_lineno`` + numbering, and those lines are marked readable for ``edit_lineno`` this turn. ``max_chars`` caps the returned slice; a ``TRUNCATE_TOKEN`` is appended so ``get_truncated`` can continue reading the rest without a new request. + + The result starts with a 1-based inclusive line range ``L{begin}-{end}`` so + the caller knows exactly which file lines were read (offset is 0-based). """ target = resolve_path(path) - if not target.is_file(): + text = read_raw_text(path) + if text is None: return f"error: not a file: {path}" - text = target.read_text(encoding="utf-8", errors="replace") lines = text.splitlines(keepends=True) if offset < 0: return "error: offset must be >= 0" @@ -51,13 +79,13 @@ async def read_file( slice_lines = lines[start:end] if with_lineno: mark_lineno_read(str(target), set(range(start + 1, end + 1))) - out = _format_with_lineno(slice_lines, start_lineno=start + 1) + body = _format_with_lineno(slice_lines, start_lineno=start + 1) else: - out = "".join(slice_lines) + body = "".join(slice_lines) if max_chars is not None and max_chars >= 1: char_start = len("".join(lines[:start])) - out, _ = truncate_with_token( - out, + body, _ = truncate_with_token( + body, max_chars, kind="file", location=path, # arg form: short token, resolves to the same file @@ -65,4 +93,10 @@ async def read_file( limit=max_chars, total_len=len(text), ) - return out + if not body: + return "" + if with_lineno: + return body # per-line numbers already show the range + begin = start + 1 + end_line = start + len(slice_lines) + return f"L{begin}-{end_line}\n{body}" diff --git a/tests/test_tools/test_edit_lineno.py b/tests/test_tools/test_edit_lineno.py index a94851c..91c1054 100644 --- a/tests/test_tools/test_edit_lineno.py +++ b/tests/test_tools/test_edit_lineno.py @@ -19,7 +19,7 @@ def test_edit_lineno_replace_middle(workspace: object) -> None: _ = _read_lineno("a.txt") out = call_sync(edit_lineno, "a.txt", 2, 3, "TWO\nTHREE\n") assert "replaced lines 2-3" in out - assert call_sync(read_file, "a.txt") == "one\nTWO\nTHREE\nfour\n" + assert call_sync(read_file, "a.txt") == "L1-4\none\nTWO\nTHREE\nfour\n" def test_edit_lineno_requires_all_lines_read(workspace: object) -> None: @@ -49,7 +49,7 @@ def test_edit_lineno_append_requires_last_line_read(workspace: object) -> None: _ = call_sync(read_file, "ap.txt", offset=2, limit=1, with_lineno=True) out = call_sync(edit_lineno, "ap.txt", 4, 4, "d\n") assert "appended" in out - assert call_sync(read_file, "ap.txt") == "a\nb\nc\nd\n" + assert call_sync(read_file, "ap.txt") == "L1-4\na\nb\nc\nd\n" def test_edit_lineno_delete_range(workspace: object) -> None: @@ -58,7 +58,7 @@ def test_edit_lineno_delete_range(workspace: object) -> None: _ = _read_lineno("b.txt") out = call_sync(edit_lineno, "b.txt", 2, 2, "") assert "replaced" in out - assert call_sync(read_file, "b.txt") == "a\nc\n" + assert call_sync(read_file, "b.txt") == "L1-2\na\nc\n" def test_edit_lineno_append(workspace: object) -> None: @@ -67,7 +67,7 @@ def test_edit_lineno_append(workspace: object) -> None: _ = _read_lineno("c.txt") out = call_sync(edit_lineno, "c.txt", 2, 2, "more\n") assert "appended" in out - assert call_sync(read_file, "c.txt") == "only\nmore\n" + assert call_sync(read_file, "c.txt") == "L1-2\nonly\nmore\n" def test_edit_lineno_invalid(workspace: object) -> None: diff --git a/tests/test_tools/test_file.py b/tests/test_tools/test_file.py index 9d62baf..e858f19 100644 --- a/tests/test_tools/test_file.py +++ b/tests/test_tools/test_file.py @@ -19,19 +19,21 @@ def _extract_token(out: str) -> str | None: def test_write_read_listdir_edit(workspace: object) -> None: del workspace assert "wrote" in call_sync(write_file, "notes/a.txt", "hello world") - assert call_sync(read_file, "notes/a.txt") == "hello world" + assert call_sync(read_file, "notes/a.txt") == "L1-1\nhello world" listing = call_sync(listdir, "notes") assert "a.txt" in listing assert "file" in listing result = call_sync(edit_replace, "notes/a.txt", "world", "there") assert "replaced" in result - assert call_sync(read_file, "notes/a.txt") == "hello there" + assert call_sync(read_file, "notes/a.txt") == "L1-1\nhello there" def test_read_file_max_chars_embeds_token(workspace: Path) -> None: (workspace / "big.txt").write_text("a" * 1000, encoding="utf-8") out = call_sync(read_file, "big.txt", max_chars=200) - content, _, marker = out.partition("\n...[") + header, _, rest = out.partition("\n") + assert header.startswith("L1-") # 1-based range of the raw slice read + content, _, marker = rest.partition("\n...[") assert content == "a" * len(content) assert len(content) < 200 assert "TRUNCATE_TOKEN:" in marker @@ -46,7 +48,7 @@ def test_read_file_max_chars_embeds_token(workspace: Path) -> None: def test_read_file_max_chars_small_file_untouched(workspace: Path) -> None: (workspace / "small.txt").write_text("tiny", encoding="utf-8") out = call_sync(read_file, "small.txt", max_chars=200) - assert out == "tiny" + assert out == "L1-1\ntiny" assert "TRUNCATE_TOKEN:" not in out @@ -54,7 +56,7 @@ def test_edit_replace_first_only(workspace: object) -> None: del workspace _ = call_sync(write_file, "t.txt", "aa aa") result = call_sync(edit_replace, "t.txt", "aa", "bb") - assert call_sync(read_file, "t.txt") == "bb aa" + assert call_sync(read_file, "t.txt") == "L1-1\nbb aa" assert "1 of 2" in result or "1 of 2 matches" in result assert "remain" in result @@ -63,7 +65,7 @@ def test_edit_replace_max_replaces(workspace: object) -> None: del workspace _ = call_sync(write_file, "t.txt", "aa aa aa") result = call_sync(edit_replace, "t.txt", "aa", "bb", max_replaces=2) - assert call_sync(read_file, "t.txt") == "bb bb aa" + assert call_sync(read_file, "t.txt") == "L1-1\nbb bb aa" assert "2 of 3" in result assert "1 remain" in result @@ -72,7 +74,7 @@ def test_edit_replace_all_matches(workspace: object) -> None: del workspace _ = call_sync(write_file, "t.txt", "aa aa") result = call_sync(edit_replace, "t.txt", "aa", "bb", max_replaces=10) - assert call_sync(read_file, "t.txt") == "bb bb" + assert call_sync(read_file, "t.txt") == "L1-1\nbb bb" assert "all 2 matches" in result @@ -91,7 +93,8 @@ def test_edit_missing_old_string(workspace: object) -> None: def test_read_offset_limit(workspace: object) -> None: del workspace _ = call_sync(write_file, "lines.txt", "a\nb\nc\nd\n") - assert call_sync(read_file, "lines.txt", offset=1, limit=2) == "b\nc\n" + # offset is 0-based: offset=1 skips line 1, so the read starts at 1-based line 2. + assert call_sync(read_file, "lines.txt", offset=1, limit=2) == "L2-3\nb\nc\n" def test_read_with_lineno(workspace: object) -> None: diff --git a/tests/test_tools/test_fs_ops.py b/tests/test_tools/test_fs_ops.py index fc2c9e2..037b244 100644 --- a/tests/test_tools/test_fs_ops.py +++ b/tests/test_tools/test_fs_ops.py @@ -11,7 +11,7 @@ def test_copy_file(workspace: object) -> None: _ = call_sync(write_file, "a.txt", "hello") out = call_sync(copy_path, "a.txt", "b.txt") assert "copied" in out - assert call_sync(read_file, "b.txt") == "hello" + assert call_sync(read_file, "b.txt") == "L1-1\nhello" assert (workspace / "a.txt").is_file() @@ -21,8 +21,8 @@ def test_copy_directory(workspace: object) -> None: _ = call_sync(write_file, "src/nested/y.txt", "y") out = call_sync(copy_path, "src", "dst") assert "directory" in out - assert call_sync(read_file, "dst/x.txt") == "x" - assert call_sync(read_file, "dst/nested/y.txt") == "y" + assert call_sync(read_file, "dst/x.txt") == "L1-1\nx" + assert call_sync(read_file, "dst/nested/y.txt") == "L1-1\ny" def test_copy_overwrite(workspace: object) -> None: @@ -32,7 +32,7 @@ def test_copy_overwrite(workspace: object) -> None: assert "exists" in call_sync(copy_path, "b.txt", "a.txt") out = call_sync(copy_path, "b.txt", "a.txt", overwrite=True) assert "copied" in out - assert call_sync(read_file, "a.txt") == "new" + assert call_sync(read_file, "a.txt") == "L1-1\nnew" def test_move_file(workspace: object) -> None: @@ -41,7 +41,7 @@ def test_move_file(workspace: object) -> None: out = call_sync(move_path, "old.txt", "renamed.txt") assert "moved" in out assert not (workspace / "old.txt").exists() - assert call_sync(read_file, "renamed.txt") == "data" + assert call_sync(read_file, "renamed.txt") == "L1-1\ndata" def test_move_directory(workspace: object) -> None: @@ -50,7 +50,7 @@ def test_move_directory(workspace: object) -> None: out = call_sync(move_path, "d", "e") assert "moved" in out assert not (workspace / "d").exists() - assert call_sync(read_file, "e/a.txt") == "a" + assert call_sync(read_file, "e/a.txt") == "L1-1\na" def test_delete_file(workspace: object) -> None: diff --git a/tests/test_tools/test_get_truncated.py b/tests/test_tools/test_get_truncated.py index 52ef9a8..f4ac9e3 100644 --- a/tests/test_tools/test_get_truncated.py +++ b/tests/test_tools/test_get_truncated.py @@ -29,7 +29,9 @@ async def test_get_truncated_file_chains_without_gap(workspace: Path) -> None: total_read = 0 for _ in range(20): out = await call_async(get_truncated, token, max_chars=200) - content, _, _ = out.partition("\n...[") + body, _, _ = out.partition("\n...[") + header, _, content = body.partition("\n") + assert header.startswith("L") and "-" in header assert content == "a" * len(content) total_read += len(content) next_token = _extract_token(out) diff --git a/tests/test_tools/test_temp_workspace.py b/tests/test_tools/test_temp_workspace.py index a9dc310..7d3073d 100644 --- a/tests/test_tools/test_temp_workspace.py +++ b/tests/test_tools/test_temp_workspace.py @@ -33,10 +33,10 @@ def test_new_temporary_workspace_allowlist(workspace: object) -> None: target = temp / "scratch.txt" _ = target.write_text("hello-temp", encoding="utf-8") assert resolve_path(str(target)) == target.resolve() - assert call_sync(read_file, str(target)) == "hello-temp" + assert call_sync(read_file, str(target)) == "L1-1\nhello-temp" _ = call_sync(write_file, "project.txt", "proj") - assert call_sync(read_file, "project.txt") == "proj" + assert call_sync(read_file, "project.txt") == "L1-1\nproj" # Sibling under system temp that we did not allowlist still fails. outside = temp.parent / "not-ours-should-fail"