mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
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:
committed by
GitHub
parent
3822192a52
commit
4c28760abb
@@ -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 == []
|
||||
|
||||
Reference in New Issue
Block a user