diff --git a/libs/deepagents/deepagents/backends/composite.py b/libs/deepagents/deepagents/backends/composite.py index c4502f935c..eb5f0d911c 100644 --- a/libs/deepagents/deepagents/backends/composite.py +++ b/libs/deepagents/deepagents/backends/composite.py @@ -6,6 +6,7 @@ """ from collections import defaultdict +from collections.abc import Sequence from typing import cast from deepagents.backends.protocol import ( @@ -79,6 +80,54 @@ def _glob_truncated(result: GlobResult | list[FileInfo]) -> bool: return result.truncated if isinstance(result, GlobResult) else False +GlobBackendResult = GlobResult | list[FileInfo] +"""Result shape accepted by composite glob merge helpers. + +Composite glob supports both current `GlobResult` values and legacy +`list[FileInfo]` backend returns. +""" + + +def _merge_glob_results( + default_result: GlobBackendResult, + routed_results: Sequence[tuple[str, GlobBackendResult]], +) -> GlobResult: + """Merge the default backend's glob result with routed backends' results. + + A backend error must not be swallowed as a partial success (mirrors the + grep merge path): the first error encountered — default first, then routes + in order — short-circuits and is surfaced instead of returning + default-only or partial matches. On success, `truncated` is OR-ed across + all sources and each routed match's path is remapped under its route prefix. + + Args: + default_result: Result from the default backend (searched at the root). + routed_results: `(route_prefix, result)` pairs from each routed backend, + in route iteration order. + + Returns: + A merged `GlobResult`, or the first erroring result unchanged. + """ + results: list[FileInfo] = [] + truncated = False + + if isinstance(default_result, GlobResult) and default_result.error: + return default_result + default_matches = default_result.matches if isinstance(default_result, GlobResult) else default_result + results.extend(default_matches or []) + truncated = truncated or _glob_truncated(default_result) + + for route_prefix, sub_result in routed_results: + if isinstance(sub_result, GlobResult) and sub_result.error: + return sub_result + sub_matches = sub_result.matches if isinstance(sub_result, GlobResult) else sub_result + results.extend(_remap_file_info_path(fi, route_prefix) for fi in (sub_matches or [])) + truncated = truncated or _glob_truncated(sub_result) + + results.sort(key=lambda x: x.get("path", "")) + return GlobResult(matches=results, truncated=truncated) + + def _route_for_path( *, default: BackendProtocol, @@ -419,9 +468,12 @@ async def agrep( return self._coerce_grep_result(await self.default.agrep(pattern, path, glob)) def glob(self, pattern: str, path: str | None = None) -> GlobResult: - """Find files matching a glob pattern, routing by path prefix.""" - results: list[FileInfo] = [] + """Find files matching a glob pattern, routing by path prefix. + Routes to backends based on path: a routed path searches that route, + `"/"` or `None` searches every backend, and a non-route path searches + only the default backend. + """ if path is not None: backend, backend_path, route_prefix = _route_for_path( default=self.default, @@ -438,34 +490,29 @@ def glob(self, pattern: str, path: str | None = None) -> GlobResult: truncated=_glob_truncated(glob_result), ) - # Path doesn't match any specific route - search default backend AND all routed backends - truncated = False - default_result = self.default.glob(pattern, path) - # A backend error must not be swallowed as a partial success (mirrors the - # grep merge path); surface it instead of returning default-only matches. - if isinstance(default_result, GlobResult) and default_result.error: - return default_result - default_matches = default_result.matches if isinstance(default_result, GlobResult) else default_result - results.extend(default_matches or []) - truncated = truncated or _glob_truncated(default_result) - - for route_prefix, backend in self.routes.items(): - route_pattern = _strip_route_from_pattern(pattern, route_prefix) - sub_result = backend.glob(route_pattern, "/") - if isinstance(sub_result, GlobResult) and sub_result.error: - return sub_result - sub_matches = sub_result.matches if isinstance(sub_result, GlobResult) else sub_result - results.extend(_remap_file_info_path(fi, route_prefix) for fi in (sub_matches or [])) - truncated = truncated or _glob_truncated(sub_result) - - # Deterministic ordering - results.sort(key=lambda x: x.get("path", "")) - return GlobResult(matches=results, truncated=truncated) + # If path is None or "/", search default and all routed backends and merge. + # Otherwise, search only the default backend. + if path is None or path == "/": + default_result = self.default.glob(pattern, path) + if isinstance(default_result, GlobResult) and default_result.error: + return _merge_glob_results(default_result, ()) + + routed_results: list[tuple[str, GlobBackendResult]] = [] + for route_prefix, backend in self.routes.items(): + sub_result = backend.glob(_strip_route_from_pattern(pattern, route_prefix), "/") + routed_results.append((route_prefix, sub_result)) + if isinstance(sub_result, GlobResult) and sub_result.error: + return _merge_glob_results(default_result, routed_results) + + return _merge_glob_results(default_result, routed_results) + + return self.default.glob(pattern, path) async def aglob(self, pattern: str, path: str | None = None) -> GlobResult: - """Async version of glob.""" - results: list[FileInfo] = [] + """Async version of glob. + See `glob()` for detailed documentation on routing behavior and parameters. + """ if path is not None: backend, backend_path, route_prefix = _route_for_path( default=self.default, @@ -482,29 +529,23 @@ async def aglob(self, pattern: str, path: str | None = None) -> GlobResult: truncated=_glob_truncated(glob_result), ) - # Path doesn't match any specific route - search default backend AND all routed backends - truncated = False - default_result = await self.default.aglob(pattern, path) - # A backend error must not be swallowed as a partial success (mirrors the - # grep merge path); surface it instead of returning default-only matches. - if isinstance(default_result, GlobResult) and default_result.error: - return default_result - default_matches = default_result.matches if isinstance(default_result, GlobResult) else default_result - results.extend(default_matches or []) - truncated = truncated or _glob_truncated(default_result) - - for route_prefix, backend in self.routes.items(): - route_pattern = _strip_route_from_pattern(pattern, route_prefix) - sub_result = await backend.aglob(route_pattern, "/") - if isinstance(sub_result, GlobResult) and sub_result.error: - return sub_result - sub_matches = sub_result.matches if isinstance(sub_result, GlobResult) else sub_result - results.extend(_remap_file_info_path(fi, route_prefix) for fi in (sub_matches or [])) - truncated = truncated or _glob_truncated(sub_result) - - # Deterministic ordering - results.sort(key=lambda x: x.get("path", "")) - return GlobResult(matches=results, truncated=truncated) + # If path is None or "/", search default and all routed backends and merge. + # Otherwise, search only the default backend. + if path is None or path == "/": + default_result = await self.default.aglob(pattern, path) + if isinstance(default_result, GlobResult) and default_result.error: + return _merge_glob_results(default_result, ()) + + routed_results: list[tuple[str, GlobBackendResult]] = [] + for route_prefix, backend in self.routes.items(): + sub_result = await backend.aglob(_strip_route_from_pattern(pattern, route_prefix), "/") + routed_results.append((route_prefix, sub_result)) + if isinstance(sub_result, GlobResult) and sub_result.error: + return _merge_glob_results(default_result, routed_results) + + return _merge_glob_results(default_result, routed_results) + + return await self.default.aglob(pattern, path) def write( self, diff --git a/libs/deepagents/tests/unit_tests/backends/test_composite_backend.py b/libs/deepagents/tests/unit_tests/backends/test_composite_backend.py index 2e91a49862..bf93b9720a 100644 --- a/libs/deepagents/tests/unit_tests/backends/test_composite_backend.py +++ b/libs/deepagents/tests/unit_tests/backends/test_composite_backend.py @@ -262,6 +262,29 @@ def test_composite_backend_grep_path_isolation(): assert not any("/memories/" in p for p in match_paths), f"grep path=/tools should not return /memories results, but got: {match_paths}" +def test_composite_backend_glob_path_isolation(): + """Test that glob with path=/tools doesn't return results from /memories.""" + mem_store = InMemoryStore() + + state = StoreBackend(store=mem_store, namespace=lambda _rt: ("default",)) + store_be = StoreBackend(store=mem_store, namespace=lambda _rt: ("filesystem",)) + + comp = CompositeBackend(default=state, routes={"/memories/": store_be}) + + comp.write("/tools/hammer.md", "tool for nailing") + comp.write("/notes/other.md", "unrelated note") + comp.write("/memories/secret.md", "private memory") + + result = comp.glob("*.md", path="/tools") + matches = result.matches + match_paths = [m["path"] for m in matches] if matches is not None else [] + + # Only /tools files: excludes routed backend (/memories) and other default dirs (/notes) + assert match_paths == ["/tools/hammer.md"] + assert "/memories/secret.md" not in match_paths + assert "/notes/other.md" not in match_paths + + def test_composite_grep_and_glob_propagate_truncated(monkeypatch: pytest.MonkeyPatch): """A truncated result from a routed/default backend must surface through the composite.""" mem_store = InMemoryStore() @@ -344,6 +367,31 @@ def test_composite_glob_merge_propagates_backend_error(monkeypatch: pytest.Monke assert result.error == "sandbox RPC failed" +def test_composite_glob_default_error_short_circuits_routes() -> None: + """A root glob default error should return before consulting routed backends.""" + + class ErrorDefaultBackend(StoreBackend): + def glob(self, pattern: str, path: str | None = None) -> GlobResult: + return GlobResult(error="Default backend error") + + class TrackingRouteBackend(StoreBackend): + def __init__(self) -> None: + super().__init__() + self.called = False + + def glob(self, pattern: str, path: str | None = None) -> GlobResult: + self.called = True + return GlobResult(matches=[]) + + routed_backend = TrackingRouteBackend() + comp = CompositeBackend(default=ErrorDefaultBackend(), routes={"/store/": routed_backend}) + + result = comp.glob("*", path="/") + + assert result.error == "Default backend error" + assert not routed_backend.called + + async def test_composite_async_merge_propagates_truncated_and_error(monkeypatch: pytest.MonkeyPatch) -> None: """`agrep`/`aglob` merge paths mirror the sync accumulation and error precedence.""" comp, default, routed = _merge_composite() diff --git a/libs/deepagents/tests/unit_tests/backends/test_composite_backend_async.py b/libs/deepagents/tests/unit_tests/backends/test_composite_backend_async.py index e1b7bd71c6..064c37e02f 100644 --- a/libs/deepagents/tests/unit_tests/backends/test_composite_backend_async.py +++ b/libs/deepagents/tests/unit_tests/backends/test_composite_backend_async.py @@ -9,6 +9,7 @@ from deepagents.backends.filesystem import FilesystemBackend from deepagents.backends.protocol import ( ExecuteResponse, + GlobResult, SandboxBackendProtocol, WriteResult, ) @@ -238,6 +239,29 @@ async def test_composite_backend_multiple_routes_async(): assert "persistent memory" in updated_content.file_data["content"] +async def test_composite_backend_aglob_path_isolation(): + """Test that aglob with path=/tools doesn't return results from /memories.""" + mem_store = InMemoryStore() + + state = StoreBackend(store=mem_store, namespace=lambda _rt: ("default",)) + store_be = StoreBackend(store=mem_store, namespace=lambda _rt: ("filesystem",)) + + comp = CompositeBackend(default=state, routes={"/memories/": store_be}) + + await comp.awrite("/tools/hammer.md", "tool for nailing") + await comp.awrite("/notes/other.md", "unrelated note") + await comp.awrite("/memories/secret.md", "private memory") + + result = await comp.aglob("*.md", path="/tools") + matches = result.matches + match_paths = [m["path"] for m in matches] if matches is not None else [] + + # Only /tools files: excludes routed backend (/memories) and other default dirs (/notes) + assert match_paths == ["/tools/hammer.md"] + assert "/memories/secret.md" not in match_paths + assert "/notes/other.md" not in match_paths + + async def test_composite_backend_als_nested_directories_async(tmp_path: Path): """Test async ls operations with nested directories.""" root = tmp_path @@ -979,6 +1003,31 @@ async def agrep(self, pattern: str, path: str | None = None, glob: str | None = assert result.error == "Default backend error" +async def test_composite_aglob_default_error_short_circuits_routes_async() -> None: + """A root glob default error should return before consulting routed backends.""" + + class ErrorDefaultBackend(StoreBackend): + async def aglob(self, pattern: str, path: str | None = None) -> GlobResult: + return GlobResult(error="Default backend error") + + class TrackingRouteBackend(StoreBackend): + def __init__(self) -> None: + super().__init__() + self.called = False + + async def aglob(self, pattern: str, path: str | None = None) -> GlobResult: + self.called = True + return GlobResult(matches=[]) + + routed_backend = TrackingRouteBackend() + comp = CompositeBackend(default=ErrorDefaultBackend(), routes={"/store/": routed_backend}) + + result = await comp.aglob("*", path="/") + + assert result.error == "Default backend error" + assert not routed_backend.called + + async def test_composite_agrep_non_root_path_on_default_backend_async(tmp_path: Path) -> None: """Test async grep with non-root path on default backend.""" root = tmp_path