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 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 ""
+12 -8
View File
@@ -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
+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
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)