core/tools: tree format markdown|flat|decorated, markdown default

The model picks the representation per call via the format param (no
user config). markdown (default) is a nested bullet list: ASCII-only,
cheapest tokens, indentation-based hierarchy the model reads reliably.
flat lists one relative path per line (dirs with trailing /, empty dirs
included) with max_entries capping the total lines, so paths are directly
usable. decorated keeps the classic box-drawing tree for humans.
This commit is contained in:
2026-08-15 17:06:12 +08:00
parent ae93128e02
commit 4fa05e2456
2 changed files with 154 additions and 5 deletions
+101 -5
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
@@ -10,6 +10,11 @@ if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
# Model-facing renderings. ``markdown`` (default) is the cheapest, most
# reliable representation for the agent; ``flat`` gives directly-usable paths;
# ``decorated`` keeps the classic box-drawing tree for human readers.
type TreeFormat = Literal["markdown", "flat", "decorated"]
DEFAULT_MAX_DEPTH = 4
DEFAULT_MAX_ENTRIES = 50
@@ -144,6 +149,80 @@ def _render_tree(
lines.append(f"{prefix}└── … ({more} more entries not shown)")
def _render_tree_markdown(
directory: Path,
*,
prefix: str,
depth: int,
limits: _TreeLimits,
lines: list[str],
) -> None:
"""Nested ``- `` bullet list (2-space indent per level)."""
if depth >= limits.max_depth:
return
children = _list_children(directory, limits=limits)
if isinstance(children, str):
lines.append(f"{prefix}- {children}")
return
truncated = len(children) > limits.max_entries
shown = children[: limits.max_entries]
for child in shown:
try:
is_dir = child.is_dir()
except OSError:
lines.append(f"{prefix}- {child.name} [error: stat failed]")
continue
if is_dir:
lines.append(f"{prefix}- {child.name}/")
_render_tree_markdown(
child,
prefix=prefix + " ",
depth=depth + 1,
limits=limits,
lines=lines,
)
else:
lines.append(f"{prefix}- {child.name}")
if truncated:
more = len(children) - limits.max_entries
lines.append(f"{prefix}- … ({more} more entries not shown)")
def _flat_paths(directory: Path, *, limits: _TreeLimits) -> list[str]:
"""All paths (dirs with trailing ``/``, files plain) in depth-first order.
Includes empty directories; bounded by *max_depth* (``max_entries`` is
applied to the total by the caller).
"""
out: list[str] = []
def visit(child_dir: Path, rel: str, depth: int) -> None:
if depth >= limits.max_depth:
return
children = _list_children(child_dir, limits=limits)
if isinstance(children, str):
out.append(f"{rel or child_dir.name} [error: cannot list]")
return
for child in children:
try:
is_dir = child.is_dir()
except OSError:
out.append(f"{child.name} [error: stat failed]")
continue
child_rel = child.name if not rel else f"{rel}/{child.name}"
if is_dir:
out.append(f"{child_rel}/")
visit(child, child_rel, depth + 1)
else:
out.append(child_rel)
visit(directory, "", 0)
return out
def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
"""None → default noise set; explicit list (including empty) replaces defaults."""
if skip_dirs is None:
@@ -155,6 +234,7 @@ def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
async def tree(
path: str = ".",
*,
format: TreeFormat = "markdown", # noqa: A002 — model-facing param name
max_depth: int = DEFAULT_MAX_DEPTH,
max_entries: int = DEFAULT_MAX_ENTRIES,
skip_hidden_dirs: bool = True,
@@ -174,8 +254,15 @@ async def tree(
``apply_path_denylist`` (default true) hides entries whose full path matches
the agent ``path_denylist`` policy.
``format`` selects the representation (the model chooses per call):
``markdown`` (default) renders a nested ``- `` bullet list — cheapest and
most reliable for the agent; ``flat`` lists one relative path per line
(directories with a trailing ``/``) so every path is directly usable
without reconstructing it; ``decorated`` uses the classic box-drawing tree.
``max_depth`` limits how deep directories are expanded (1 = origin + children).
``max_entries`` caps how many entries are listed per directory.
``max_entries`` caps entries per directory (``markdown``/``decorated``) or
the total number of lines (``flat``).
"""
if max_depth < 1:
return "error: max_depth must be >= 1"
@@ -189,8 +276,6 @@ async def tree(
if not origin.is_dir():
return f"error: not a directory: {path}"
root_label = path.rstrip("/\\") or "."
lines = [f"{root_label}/"]
limits = _TreeLimits(
max_depth=max_depth,
max_entries=max_entries,
@@ -198,7 +283,18 @@ async def tree(
skip_basenames=_resolve_skip_basenames(skip_dirs),
apply_path_denylist=apply_path_denylist,
)
_render_tree(
if format == "flat":
paths = _flat_paths(origin, limits=limits)
lines = paths[:max_entries]
if len(paths) > max_entries:
lines.append(f"… ({len(paths) - max_entries} more paths not shown)")
return "\n".join(lines)
root_label = path.rstrip("/\\") or "."
lines: list[str] = [] if format == "markdown" else [f"{root_label}/"]
renderer = _render_tree_markdown if format == "markdown" else _render_tree
renderer(
origin,
prefix="",
depth=0,
+53
View File
@@ -67,7 +67,60 @@ def test_tree_origin_subdir(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, "src")
assert out.startswith("- ")
assert "- nested/" in out
assert "main.py" in out
assert "deep.txt" in out
def test_tree_default_is_markdown(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, ".")
assert out.startswith("- ")
assert "- src/" in out
assert "├──" not in out
def test_tree_markdown_format(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, ".", format="markdown", max_depth=3, max_entries=10)
assert "- src/" in out
# nested bullets are indented by 2 spaces per level
assert " - main.py" in out
assert " - nested/" in out
assert " - deep.txt" in out
assert "more entries not shown" in out
def test_tree_flat_format(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, ".", format="flat", max_entries=200)
assert "src/" in out # dirs keep a trailing slash
assert "src/main.py" in out
assert "src/nested/deep.txt" in out
assert "a.txt" in out
assert "- " not in out
assert "├──" not in out
def test_tree_flat_total_cap(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, "pkg", format="flat", max_depth=2, max_entries=10)
paths = [line for line in out.splitlines() if not line.startswith("…")]
assert len(paths) == 10
assert "more paths not shown" in out
def test_tree_decorated_format(workspace: object) -> None:
assert isinstance(workspace, Path)
_build_sample(workspace)
out = call_sync(tree, "src", format="decorated")
assert out.startswith("src/")
assert "├──" in out
assert "main.py" in out
assert "deep.txt" in out