Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 91 additions & 50 deletions libs/deepagents/deepagents/backends/composite.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

from collections import defaultdict
from collections.abc import Sequence
from typing import cast

from deepagents.backends.protocol import (
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from deepagents.backends.filesystem import FilesystemBackend
from deepagents.backends.protocol import (
ExecuteResponse,
GlobResult,
SandboxBackendProtocol,
WriteResult,
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading