feat(tools): register list_directory agent tool for coding mode

The coding-mode prompt already documents list_directory as a file-IO tool,
but no such tool was registered, so agents calling it got
ToolNotFoundError. Add a real list_directory tool to file_io.py:

- Mirrors upstream qwenpaw.services.workspace_files.list_directory semantics
  (skip dotfiles/.git/.venv/__pycache__/node_modules, dirs first).
- Relative paths resolve from the agent workspace (consistent with
  read_file/write_file/edit_file); absolute paths used as-is.
- Caps output at 200 entries (500 hard ceiling) with omission notice so
  huge directories don't flood context.
- Governance policy 'ListDirectory' (read-only, default allow); registered
  via agents/tools/__init__.py, auto-collected by tool_descriptor.
This commit is contained in:
Pine
2026-09-04 14:42:42 +08:00
parent 55f8d9f81f
commit 8ff6ab8763
2 changed files with 134 additions and 0 deletions
+1
View File
@@ -41,6 +41,7 @@ from .file_io import ( # noqa: E402
write_file,
edit_file,
append_file,
list_directory,
)
from .file_search import grep_search, glob_search # noqa: E402
from .shell import execute_shell_command # noqa: E402
+133
View File
@@ -527,3 +527,136 @@ async def append_file(
),
],
)
# ---------------------------------------------------------------------------
# list_directory
# ---------------------------------------------------------------------------
# Entries the file browser and this tool agree are never worth surfacing to
# the model (mirrors upstream qwenpaw.services.workspace_files._SKIPPED_NAMES).
_LIST_DIRECTORY_SKIPPED_NAMES = frozenset(
{
".git",
".hypothesis",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".venv",
"__pycache__",
"node_modules",
},
)
# Hard cap so a huge directory never floods the model's context. The tool
# reports the count of omitted entries so the model can ask for a subtree.
_LIST_DIRECTORY_MAX_ENTRIES = 200
@tool_descriptor(
requires_sandbox=("file_read",),
async_execution=True,
tool_type="file",
target_param="file_path",
policy_name="ListDirectory",
default_policy="allow",
policy_reason="Read-only directory listing (global)",
ui_description="List directory contents",
ui_icon="📁",
)
async def list_directory(
file_path: str = ".",
max_entries: int = _LIST_DIRECTORY_MAX_ENTRIES,
) -> ToolChunk:
"""List the immediate children of a directory.
Use this to discover what a directory contains before reading or
editing files in it. Returns one line per entry, directories first,
each prefixed with ``D`` (directory) or ``F`` (file) plus the file
size in bytes.
Args:
file_path (`str`):
Directory to list. An absolute path is used as-is; a
relative path resolves from the agent workspace. A trailing
``/`` or ``\\`` is allowed.
max_entries (`int`, optional):
Maximum number of entries to return (default 200). The tool
reports how many entries were omitted so you can drill into
a subtree instead of requesting a bigger page.
"""
try:
resolved = Path(_resolve_file_path(file_path))
except Exception as exc: # pragma: no cover - defensive
return ToolChunk(
is_last=True,
state=ToolResultState.ERROR,
content=[
TextBlock(
type="text",
text=f"Error: Failed to resolve directory {file_path!r}: {exc}",
),
],
)
if not resolved.exists():
return ToolChunk(
is_last=True,
state=ToolResultState.ERROR,
content=[
TextBlock(
type="text",
text=f"Error: The directory {resolved} does not exist.",
),
],
)
if not resolved.is_dir():
return ToolChunk(
is_last=True,
state=ToolResultState.ERROR,
content=[
TextBlock(
type="text",
text=f"Error: The path {resolved} is not a directory.",
),
],
)
limit = min(max(1, int(max_entries)), 500)
entries: list[tuple[str, str, int]] = [] # (kind, name, size)
with os.scandir(resolved) as scanner:
for entry in scanner:
name = entry.name
if name.startswith(".") or name in _LIST_DIRECTORY_SKIPPED_NAMES:
continue
try:
is_dir = entry.is_dir(follow_symlinks=False)
size = 0 if is_dir else entry.stat(follow_symlinks=False).st_size
except OSError:
# Broken symlink / permission — still surface the name so the
# model knows it exists, marked as a file with unknown size.
is_dir = False
size = -1
entries.append(("D" if is_dir else "F", name, size))
entries.sort(key=lambda item: (item[0] != "D", item[2], item[1]))
omitted = max(0, len(entries) - limit)
visible = entries[:limit]
lines = [f"{resolved}/ — {len(entries)} entries"]
for kind, name, size in visible:
if kind == "D":
lines.append(f"D {name}/")
else:
size_str = f"{size} B" if size >= 0 else "?"
lines.append(f"F {name} {size_str}")
if omitted:
lines.append(f"(… {omitted} more entries omitted; list a subdirectory to drill in)")
return ToolChunk(
is_last=True,
state=ToolResultState.SUCCESS,
content=[TextBlock(type="text", text="\n".join(lines))],
)