From e4ed89b39119ada4bd5ba34a76167a526cba3b96 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Tue, 11 Aug 2026 15:37:56 +0800 Subject: [PATCH] core/tools: distinguish missing files from non-files on read --- src/plyngent/tools/file/get_truncated.py | 9 ++++++--- src/plyngent/tools/file/read.py | 20 +++++++++++++------- tests/test_tools/test_file.py | 6 ++++++ 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/plyngent/tools/file/get_truncated.py b/src/plyngent/tools/file/get_truncated.py index dfb5da8..3886420 100644 --- a/src/plyngent/tools/file/get_truncated.py +++ b/src/plyngent/tools/file/get_truncated.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import cast + from plyngent.agent import ToolTag, tool from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS from plyngent.tools.file.read import line_range_label, read_raw_text @@ -24,9 +26,10 @@ async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_ return "error: invalid truncate token" if parsed.kind == "http": return await fetch.handler(parsed.location, offset=parsed.offset, max_chars=parsed.limit) - text = read_raw_text(parsed.location) - if text is None: - return f"error: not a file: {parsed.location}" + text, err = read_raw_text(parsed.location) + if err: + return err + text = cast("str", text) start = parsed.offset segment = text[start : start + max_chars] chunk, _ = truncate_with_token( diff --git a/src/plyngent/tools/file/read.py b/src/plyngent/tools/file/read.py index e6fc6cc..f72ac4d 100644 --- a/src/plyngent/tools/file/read.py +++ b/src/plyngent/tools/file/read.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import cast + from plyngent.agent import ToolTag, mark_lineno_read, tool from plyngent.tools.truncate_token import truncate_with_token from plyngent.tools.workspace import resolve_path @@ -31,16 +33,19 @@ def line_range_label(text: str, start_char: int, end_char: int) -> str: 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. +def read_raw_text(path: str) -> tuple[str | None, str]: + """(text, error) for a workspace file read; ``error`` is ``""`` on success. 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). + Missing paths and directories are distinguished for the caller. """ target = resolve_path(path) + if not target.exists(): + return None, f"error: file not found: {path}" if not target.is_file(): - return None - return target.read_text(encoding="utf-8", errors="replace") + return None, f"error: not a file: {path}" + return target.read_text(encoding="utf-8", errors="replace"), "" @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE) @@ -66,9 +71,10 @@ async def read_file( the caller knows exactly which file lines were read (offset is 0-based). """ target = resolve_path(path) - text = read_raw_text(path) - if text is None: - return f"error: not a file: {path}" + text, err = read_raw_text(path) + if err: + return err + text = cast("str", text) lines = text.splitlines(keepends=True) if offset < 0: return "error: offset must be >= 0" diff --git a/tests/test_tools/test_file.py b/tests/test_tools/test_file.py index e858f19..80571c3 100644 --- a/tests/test_tools/test_file.py +++ b/tests/test_tools/test_file.py @@ -97,6 +97,12 @@ def test_read_offset_limit(workspace: object) -> None: assert call_sync(read_file, "lines.txt", offset=1, limit=2) == "L2-3\nb\nc\n" +def test_read_file_missing_and_directory(workspace: Path) -> None: + (workspace / "sub").mkdir() + assert call_sync(read_file, "nope.txt") == "error: file not found: nope.txt" + assert call_sync(read_file, "sub") == "error: not a file: sub" + + def test_read_with_lineno(workspace: object) -> None: del workspace _ = call_sync(write_file, "num.txt", "a\nb\nc\n")