core/tools: default read_file truncation with token (no hint when uncut)

This commit is contained in:
2026-08-11 18:55:14 +08:00
parent 26472776fb
commit b5a30190f6
3 changed files with 37 additions and 15 deletions
+10 -7
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import cast from typing import cast
from plyngent.agent import ToolTag, mark_lineno_read, tool 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.truncate_token import truncate_with_token
from plyngent.tools.workspace import resolve_path from plyngent.tools.workspace import resolve_path
@@ -55,7 +56,7 @@ async def read_file(
offset: int = 0, offset: int = 0,
limit: int | None = None, limit: int | None = None,
with_lineno: bool = False, with_lineno: bool = False,
max_chars: int | None = None, max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
) -> str: ) -> str:
"""Read a text file under the workspace. """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`` with its 1-based file line number (`` N|…``), matching ``edit_lineno``
numbering, and those lines are marked readable for ``edit_lineno`` this turn. numbering, and those lines are marked readable for ``edit_lineno`` this turn.
``max_chars`` caps the returned slice; a ``TRUNCATE_TOKEN`` is appended so ``max_chars`` caps the returned slice (default: the agent's tool-result cap,
``get_truncated`` can continue reading the rest without a new request. ``DEFAULT_TOOL_RESULT_MAX_CHARS``); a ``truncate_token`` is appended when cut
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 Pass ``max_chars < 1`` to read unbounded.
the caller knows exactly which file lines were read (offset is 0-based).
""" """
target = resolve_path(path) target = resolve_path(path)
text, err = read_raw_text(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) body = _format_with_lineno(slice_lines, start_lineno=start + 1)
else: else:
body = "".join(slice_lines) body = "".join(slice_lines)
if max_chars is not None and max_chars >= 1: if max_chars >= 1:
char_start = len("".join(lines[:start])) char_start = len("".join(lines[:start]))
body, _ = truncate_with_token( body, _ = truncate_with_token(
body, body,
@@ -98,6 +98,9 @@ async def read_file(
offset=char_start, offset=char_start,
limit=max_chars, limit=max_chars,
total_len=len(text), 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: if not body:
return "" return ""
+12 -8
View File
@@ -95,17 +95,19 @@ def truncate_with_token(
offset: int, offset: int,
limit: int, limit: int,
total_len: int, total_len: int,
emit_more_after: bool = True,
) -> tuple[str, TruncateToken | None]: ) -> tuple[str, TruncateToken | None]:
"""Bound ``text`` (a slice of the full source starting at ``offset``) to ``max_chars``. """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 Returns ``(bounded_text, next_token)``. A token is embedded whenever the
source content remains after the returned chunk, so ``get_truncated`` returned chunk is cut (overflow) or, with ``emit_more_after`` (default),
chains through truncations; each chunk may itself carry a fresh token. The whenever more source content remains after the chunk — so ``get_truncated``
token advances by the length actually returned (no content is skipped), and chains through truncations. The token advances by the length actually
the marker is budgeted so the total returned length stays near returned (no content is skipped), and the marker is budgeted so the total
``max_chars`` (the agent loop's own cap never re-cuts it and never strips returned length stays near ``max_chars`` (the agent loop's own cap never
the token). ``omitted`` in the marker counts source characters not yet re-cuts it and never strips the token). ``omitted`` in the marker counts
delivered. When nothing was cut and the source ends, ``text`` is returned 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. unchanged with no marker.
""" """
if max_chars < 1: if max_chars < 1:
@@ -120,6 +122,8 @@ def truncate_with_token(
return text[:max_chars] + truncation_marker(max_chars, omitted, first), first return text[:max_chars] + truncation_marker(max_chars, omitted, first), first
token = TruncateToken(kind=kind, location=location, offset=offset + cut, limit=limit) token = TruncateToken(kind=kind, location=location, offset=offset + cut, limit=limit)
return text[:cut] + truncation_marker(max_chars, omitted, token), token 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 more_after = offset + len(text) < total_len
if not more_after: if not more_after:
return text, None return text, None
+15
View File
@@ -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 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: def test_read_file_max_chars_small_file_untouched(workspace: Path) -> None:
(workspace / "small.txt").write_text("tiny", encoding="utf-8") (workspace / "small.txt").write_text("tiny", encoding="utf-8")
out = call_sync(read_file, "small.txt", max_chars=200) out = call_sync(read_file, "small.txt", max_chars=200)