diff --git a/src/plyngent/tools/file/read.py b/src/plyngent/tools/file/read.py index f72ac4d..a8b75bf 100644 --- a/src/plyngent/tools/file/read.py +++ b/src/plyngent/tools/file/read.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import cast from plyngent.agent import ToolTag, mark_lineno_read, tool +from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS from plyngent.tools.truncate_token import truncate_with_token from plyngent.tools.workspace import resolve_path @@ -55,7 +56,7 @@ async def read_file( offset: int = 0, limit: int | None = None, with_lineno: bool = False, - max_chars: int | None = None, + max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS, ) -> str: """Read a text file under the workspace. @@ -64,11 +65,10 @@ async def read_file( 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). + ``max_chars`` caps the returned slice (default: the agent's tool-result cap, + ``DEFAULT_TOOL_RESULT_MAX_CHARS``); a ``truncate_token`` is appended when cut + so ``get_truncated`` can continue reading the rest without a new request. + Pass ``max_chars < 1`` to read unbounded. """ target = resolve_path(path) text, err = read_raw_text(path) @@ -88,7 +88,7 @@ async def read_file( body = _format_with_lineno(slice_lines, start_lineno=start + 1) else: body = "".join(slice_lines) - if max_chars is not None and max_chars >= 1: + if max_chars >= 1: char_start = len("".join(lines[:start])) body, _ = truncate_with_token( body, @@ -98,6 +98,9 @@ async def read_file( offset=char_start, limit=max_chars, total_len=len(text), + # read_file bounds the read via offset/limit; only mark when the + # returned slice itself was cut by max_chars (not just "file continues"). + emit_more_after=False, ) if not body: return "" diff --git a/src/plyngent/tools/truncate_token.py b/src/plyngent/tools/truncate_token.py index be3af0e..efc207d 100644 --- a/src/plyngent/tools/truncate_token.py +++ b/src/plyngent/tools/truncate_token.py @@ -95,17 +95,19 @@ def truncate_with_token( offset: int, limit: int, total_len: int, + emit_more_after: bool = True, ) -> tuple[str, TruncateToken | None]: """Bound ``text`` (a slice of the full source starting at ``offset``) to ``max_chars``. - Returns ``(bounded_text, next_token)``. A token is embedded whenever more - source content remains after the returned chunk, so ``get_truncated`` - chains through truncations; each chunk may itself carry a fresh token. The - token advances by the length actually returned (no content is skipped), and - the marker is budgeted so the total returned length stays near - ``max_chars`` (the agent loop's own cap never re-cuts it and never strips - the token). ``omitted`` in the marker counts source characters not yet - delivered. When nothing was cut and the source ends, ``text`` is returned + Returns ``(bounded_text, next_token)``. A token is embedded whenever the + returned chunk is cut (overflow) or, with ``emit_more_after`` (default), + whenever more source content remains after the chunk — so ``get_truncated`` + chains through truncations. The token advances by the length actually + returned (no content is skipped), and the marker is budgeted so the total + returned length stays near ``max_chars`` (the agent loop's own cap never + re-cuts it and never strips the token). ``omitted`` in the marker counts + source characters not yet delivered. When nothing was cut and + (``emit_more_after=False`` or the source ends), ``text`` is returned unchanged with no marker. """ if max_chars < 1: @@ -120,6 +122,8 @@ def truncate_with_token( return text[:max_chars] + truncation_marker(max_chars, omitted, first), first token = TruncateToken(kind=kind, location=location, offset=offset + cut, limit=limit) return text[:cut] + truncation_marker(max_chars, omitted, token), token + if not emit_more_after: + return text, None more_after = offset + len(text) < total_len if not more_after: return text, None diff --git a/tests/test_tools/test_file.py b/tests/test_tools/test_file.py index 9fe58fd..8258eae 100644 --- a/tests/test_tools/test_file.py +++ b/tests/test_tools/test_file.py @@ -45,6 +45,21 @@ def test_read_file_max_chars_embeds_token(workspace: Path) -> None: assert parsed.offset == len(content) # cursor lands exactly where content stops +def test_read_file_default_truncates_at_cap(workspace: Path) -> None: + """Truncation is the default: a large read embeds a token without max_chars.""" + (workspace / "huge.txt").write_text("\n".join(f"line-{i}" for i in range(10_000)), encoding="utf-8") + out = call_sync(read_file, "huge.txt") + header, _, rest = out.partition("\n") + assert header == "L1-10000" # the requested slice (whole file) + assert "truncate_token=" in rest + # The slice is capped near the default tool-result cap. + assert len(rest) <= 32_000 + # No marker when the file is small (no truncation occurred). + small = call_sync(read_file, "huge.txt", max_chars=0) + assert "Truncated" not in small + assert small.startswith("L1-10000\n") + + 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)