mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
core/tools: default read_file truncation with token (no hint when uncut)
This commit is contained in:
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user