fix(sdk): surface missing path errors in FilesystemBackend.ls (#3574)

Closes #3573

---

> [!WARNING]
> **Behavior change for direct backend callers.** Code calling
`FilesystemBackend.ls()` / `als()` directly (not through
`FilesystemMiddleware`) now receives `entries=None` instead of
`entries=[]` on **every** failure path — missing path, not-a-directory,
and resolver errors (e.g. symlink loops). This realigns `ls` with the
long-documented `LsResult` contract (`entries` is `None` on failure),
and the middleware was already null-safe (`ls_result.entries or []`), so
the tool surface is unaffected. But a direct caller that assumed
`entries` was always a list will now hit `None` on these paths. Treat
this as a **contract correction**, not a purely additive change.

## Behavior

`FilesystemBackend.ls(path)` now tells callers *why* a listing is empty
instead of collapsing every case into `entries=[]`.

| You call `ls` on… | Returns |
|---|---|
| A missing path | `error="Path '<path>': path_not_found"`,
`entries=None` |
| A file (not a directory) | `error="Path '<path>': not_a_directory"`,
`entries=None` |
| A path that errors while resolving (e.g. symlink loop) |
`error="Cannot list '<path>': <reason>"`, `entries=None` *(was
`entries=[]`)* |
| An empty directory | `error=None`, `entries=[]` *(unchanged)* |
| A populated directory | `error=None`, `entries=[...]` *(unchanged)* |

So an agent can finally distinguish "that path isn't there" from "that
directory exists but is empty."

## Why

Previously the success-like cases above returned `error=None,
entries=[]`. A missing path looked identical to an empty directory, so
an agent calling the `ls` tool would conclude a directory exists and is
empty when really the path was wrong or gone — a silent failure that
sends the agent down the wrong branch.

This also brings the local backend in line with the sandbox backends,
which already surface `path_not_found` / `not_a_directory` errors. Agent
reasoning shouldn't change based on which backend is wired in.

## Scope

- Only `FilesystemBackend.ls()` changed; `LsResult.error` already
existed and `FilesystemMiddleware` already checks it before formatting
output, so no middleware changes were needed.
- `ls()` now upholds a single failure contract: **every** error path
returns `entries=None` (the `LsResult` docstring's documented "`None` on
failure"). This includes the pre-existing `OSError`/`RuntimeError`
resolver-failure path, which previously returned `entries=[]` alongside
its error.
- Async `als()` inherits the fix via `BackendProtocol.als()`.
- Tests updated in the composite and filesystem backend suites (sync +
async) to assert the new `error` / `entries=None` contract, including
the symlink-loop resolver-failure path.

---------

Co-authored-by: Mason Daugherty <mason@langchain.dev>
Co-authored-by: Mason Daugherty <github@mdrxy.com>
This commit is contained in:
Mohammad Mohtashim
2026-06-03 03:00:07 +05:00
committed by GitHub
parent 3822192a52
commit 4c28760abb
4 changed files with 93 additions and 13 deletions
@@ -240,18 +240,29 @@ class FilesystemBackend(BackendProtocol):
path: Absolute directory path to list files from.
Returns:
List of `FileInfo`-like dicts for files and directories directly in the
directory. Directories have a trailing `/` in their path and
`is_dir=True`.
`LsResult` with `entries` listing files and directories directly in the
directory on success.
Directories have a trailing `/` in their path and `is_dir=True`.
Missing paths set `error` to `Path '<path>': path_not_found`
with `entries=None`.
File paths set `error` to `Path '<path>': not_a_directory`
with `entries=None`.
Empty directories return `error=None` and `entries=[]`.
"""
try:
dir_path = self._resolve_path(path)
if not dir_path.exists() or not dir_path.is_dir():
return LsResult(entries=[])
if not dir_path.exists():
return LsResult(error=f"Path '{path}': path_not_found", entries=None)
if not dir_path.is_dir():
return LsResult(error=f"Path '{path}': not_a_directory", entries=None)
except (OSError, RuntimeError) as e:
msg = f"Cannot list '{path}': {e}"
logger.warning("%s", msg)
return LsResult(error=msg, entries=[])
return LsResult(error=msg, entries=None)
results: list[FileInfo] = []
errors: list[str] = []
@@ -403,8 +403,9 @@ def test_composite_backend_ls_trailing_slash(tmp_path: Path):
empty_listing = comp.ls("/store/nonexistent/")
assert empty_listing.entries == []
empty_listing2 = comp.ls("/nonexistent/")
assert empty_listing2.entries == []
missing_listing = comp.ls("/nonexistent/")
assert missing_listing.entries is None
assert missing_listing.error == "Path '/nonexistent/': path_not_found"
listing1 = comp.ls("/store/").entries
listing2 = comp.ls("/store").entries
@@ -171,7 +171,8 @@ def test_filesystem_backend_ls_nested_directories(tmp_path: Path):
assert len(utils_paths) == 2
empty_listing = be.ls("/nonexistent/")
assert empty_listing.entries == []
assert empty_listing.entries is None
assert empty_listing.error == "Path '/nonexistent/': path_not_found"
def test_filesystem_backend_ls_normal_mode_nested(tmp_path: Path):
@@ -236,7 +237,8 @@ def test_filesystem_backend_ls_trailing_slash(tmp_path: Path):
assert [fi["path"] for fi in listing1] == [fi["path"] for fi in listing2]
empty = be.ls("/nonexistent/")
assert empty.entries == []
assert empty.entries is None
assert empty.error == "Path '/nonexistent/': path_not_found"
def test_filesystem_backend_read_non_utf8_file(tmp_path: Path):
@@ -1065,6 +1067,38 @@ class TestEditCrlfNormalization:
assert "Human: next" in final
def test_ls_nonexistent_path_sets_error(tmp_path: Path) -> None:
"""Ls on a missing path must surface the failure on .error, not return []."""
be = FilesystemBackend(root_dir=str(tmp_path), virtual_mode=True)
result = be.ls("/missing/")
assert result.entries is None
assert result.error == "Path '/missing/': path_not_found"
def test_ls_file_path_sets_not_a_directory_error(tmp_path: Path) -> None:
"""Ls on a file path must surface not_a_directory on .error."""
write_file(tmp_path / "file.txt", "content")
be = FilesystemBackend(root_dir=str(tmp_path), virtual_mode=True)
result = be.ls("/file.txt")
assert result.entries is None
assert result.error == "Path '/file.txt': not_a_directory"
def test_ls_empty_directory_returns_empty_entries(tmp_path: Path) -> None:
"""Ls on an empty directory returns success with an empty entries list."""
(tmp_path / "empty").mkdir()
be = FilesystemBackend(root_dir=str(tmp_path), virtual_mode=True)
result = be.ls("/empty/")
assert result.error is None
assert result.entries == []
def test_ls_symlink_loop_path_returns_structured_error(tmp_path: Path) -> None:
"""A resolver failure in `ls` should not escape the backend boundary."""
make_symlink_loop(tmp_path / "loop")
@@ -1072,7 +1106,7 @@ def test_ls_symlink_loop_path_returns_structured_error(tmp_path: Path) -> None:
be = FilesystemBackend(root_dir=str(tmp_path), virtual_mode=False)
result = be.ls("loop")
assert result.entries == []
assert result.entries is None
assert result.error is not None
assert "Cannot list 'loop'" in result.error
@@ -141,7 +141,8 @@ async def test_filesystem_backend_als_nested_directories(tmp_path: Path):
assert len(utils_paths) == 2
empty_listing = await be.als("/nonexistent/")
assert empty_listing.entries == []
assert empty_listing.entries is None
assert empty_listing.error == "Path '/nonexistent/': path_not_found"
async def test_filesystem_backend_als_normal_mode_nested(tmp_path: Path):
@@ -206,7 +207,8 @@ async def test_filesystem_backend_als_trailing_slash(tmp_path: Path):
assert [fi["path"] for fi in listing1] == [fi["path"] for fi in listing2]
empty = await be.als("/nonexistent/")
assert empty.entries == []
assert empty.entries is None
assert empty.error == "Path '/nonexistent/': path_not_found"
async def test_filesystem_backend_intercept_large_tool_result_async(tmp_path: Path):
@@ -532,3 +534,35 @@ async def test_filesystem_aglob_recursive(tmp_path: Path):
assert any("helper.py" in p for p in py_files)
assert any("test_main.py" in p for p in py_files)
assert not any("readme.txt" in p for p in py_files)
async def test_als_nonexistent_path_sets_error(tmp_path: Path) -> None:
"""Async ls on a missing path must surface the failure on .error, not return []."""
be = FilesystemBackend(root_dir=str(tmp_path), virtual_mode=True)
result = await be.als("/missing/")
assert result.entries is None
assert result.error == "Path '/missing/': path_not_found"
async def test_als_file_path_sets_not_a_directory_error(tmp_path: Path) -> None:
"""Async ls on a file path must surface not_a_directory on .error."""
write_file(tmp_path / "file.txt", "content")
be = FilesystemBackend(root_dir=str(tmp_path), virtual_mode=True)
result = await be.als("/file.txt")
assert result.entries is None
assert result.error == "Path '/file.txt': not_a_directory"
async def test_als_empty_directory_returns_empty_entries(tmp_path: Path) -> None:
"""Async ls on an empty directory returns success with an empty entries list."""
(tmp_path / "empty").mkdir()
be = FilesystemBackend(root_dir=str(tmp_path), virtual_mode=True)
result = await be.als("/empty/")
assert result.error is None
assert result.entries == []