core/tools/file: add tree with depth, entry, and VCS filters

Workspace-relative tree; always skip VCS dirs; skip hidden dirs by
default; max_depth and max_entries caps.
This commit is contained in:
2026-07-14 20:19:49 +08:00
parent 4f1e99fa4b
commit f079ab73df
5 changed files with 238 additions and 2 deletions
+1
View File
@@ -2,6 +2,7 @@ from .file import FILE_TOOLS as FILE_TOOLS
from .file import edit_replace as edit_replace
from .file import listdir as listdir
from .file import read_file as read_file
from .file import tree as tree
from .file import write_file as write_file
from .process import PROCESS_TOOLS as PROCESS_TOOLS
from .process import close_pty as close_pty
+2 -1
View File
@@ -1,6 +1,7 @@
from .edit_replace import edit_replace as edit_replace
from .listdir import listdir as listdir
from .read import read_file as read_file
from .tree import tree as tree
from .write import write_file as write_file
FILE_TOOLS = [read_file, write_file, listdir, edit_replace]
FILE_TOOLS = [read_file, write_file, listdir, tree, edit_replace]
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from plyngent.agent import tool
from plyngent.tools.workspace import WorkspaceError, resolve_path
if TYPE_CHECKING:
from pathlib import Path
DEFAULT_MAX_DEPTH = 4
DEFAULT_MAX_ENTRIES = 50
# Always skipped directory basenames (VCS metadata).
VCS_DIR_NAMES: frozenset[str] = frozenset(
{
".git",
".hg",
".svn",
".bzr",
"CVS",
".jj",
"_darcs",
".fossil",
}
)
@dataclass(frozen=True, slots=True)
class _TreeLimits:
max_depth: int
max_entries: int
skip_hidden_dirs: bool
def _skip_directory(name: str, *, skip_hidden_dirs: bool) -> bool:
if name in VCS_DIR_NAMES:
return True
return bool(skip_hidden_dirs and name.startswith("."))
def _list_children(directory: Path, *, skip_hidden_dirs: bool) -> list[Path] | str:
try:
children = list(directory.iterdir())
except OSError as exc:
return f"error: cannot list {directory.name}: {exc}"
visible: list[Path] = []
for child in children:
try:
is_dir = child.is_dir()
except OSError:
continue
if is_dir and _skip_directory(child.name, skip_hidden_dirs=skip_hidden_dirs):
continue
visible.append(child)
# Directories first, then files; alphabetical within each group.
visible.sort(key=lambda p: (not p.is_dir(), p.name.casefold()))
return visible
def _render_tree(
directory: Path,
*,
prefix: str,
depth: int,
limits: _TreeLimits,
lines: list[str],
) -> None:
if depth >= limits.max_depth:
return
children = _list_children(directory, skip_hidden_dirs=limits.skip_hidden_dirs)
if isinstance(children, str):
lines.append(f"{prefix}{children}")
return
truncated = len(children) > limits.max_entries
shown = children[: limits.max_entries]
for index, child in enumerate(shown):
is_last = index == len(shown) - 1 and not truncated
branch = "└── " if is_last else "├── "
extension = " " if is_last else ""
try:
is_dir = child.is_dir()
except OSError:
lines.append(f"{prefix}{branch}{child.name} [error: stat failed]")
continue
if is_dir:
lines.append(f"{prefix}{branch}{child.name}/")
if depth + 1 < limits.max_depth:
_render_tree(
child,
prefix=prefix + extension,
depth=depth + 1,
limits=limits,
lines=lines,
)
else:
lines.append(f"{prefix}{branch}{child.name}")
if truncated:
more = len(children) - limits.max_entries
lines.append(f"{prefix}└── … ({more} more entries not shown)")
@tool
def tree(
path: str = ".",
*,
max_depth: int = DEFAULT_MAX_DEPTH,
max_entries: int = DEFAULT_MAX_ENTRIES,
skip_hidden_dirs: bool = True,
) -> str:
"""Show a directory tree under the workspace.
Always skips VCS metadata directories (``.git``, ``.hg``, ``.svn``, …).
By default skips other dot-directories (not hidden files). Use
``skip_hidden_dirs=false`` to include them.
``max_depth`` limits how deep directories are expanded (1 = origin + children).
``max_entries`` caps how many entries are listed per directory.
"""
if max_depth < 1:
return "error: max_depth must be >= 1"
if max_entries < 1:
return "error: max_entries must be >= 1"
try:
origin = resolve_path(path)
except WorkspaceError as exc:
return f"error: {exc}"
if not origin.is_dir():
return f"error: not a directory: {path}"
root_label = path.rstrip("/\\") or "."
lines = [f"{root_label}/"]
_render_tree(
origin,
prefix="",
depth=0,
limits=_TreeLimits(
max_depth=max_depth,
max_entries=max_entries,
skip_hidden_dirs=skip_hidden_dirs,
),
lines=lines,
)
return "\n".join(lines)