feat(filesystem): add multi-root local folder support in backend

This commit is contained in:
Anish Sarkar 2026-04-24 01:44:23 +05:30
parent daac6b5269
commit 6721919398
7 changed files with 422 additions and 30 deletions

View file

@ -9,26 +9,27 @@ from deepagents.backends.state import StateBackend
from langgraph.prebuilt.tool_node import ToolRuntime
from app.agents.new_chat.filesystem_selection import FilesystemMode, FilesystemSelection
from app.agents.new_chat.middleware.local_folder_backend import LocalFolderBackend
from app.agents.new_chat.middleware.multi_root_local_folder_backend import (
MultiRootLocalFolderBackend,
)
@lru_cache(maxsize=64)
def _cached_local_backend(root_path: str) -> LocalFolderBackend:
return LocalFolderBackend(root_path)
def _cached_multi_root_backend(
root_paths: tuple[str, ...],
) -> MultiRootLocalFolderBackend:
return MultiRootLocalFolderBackend(root_paths)
def build_backend_resolver(
selection: FilesystemSelection,
) -> Callable[[ToolRuntime], StateBackend | LocalFolderBackend]:
) -> Callable[[ToolRuntime], StateBackend | MultiRootLocalFolderBackend]:
"""Create deepagents backend resolver for the selected filesystem mode."""
if (
selection.mode == FilesystemMode.DESKTOP_LOCAL_FOLDER
and selection.local_root_path is not None
):
if selection.mode == FilesystemMode.DESKTOP_LOCAL_FOLDER and selection.local_root_paths:
def _resolve_local(_runtime: ToolRuntime) -> LocalFolderBackend:
return _cached_local_backend(selection.local_root_path or "")
def _resolve_local(_runtime: ToolRuntime) -> MultiRootLocalFolderBackend:
return _cached_multi_root_backend(selection.local_root_paths)
return _resolve_local

View file

@ -26,7 +26,7 @@ class FilesystemSelection:
mode: FilesystemMode = FilesystemMode.CLOUD
client_platform: ClientPlatform = ClientPlatform.WEB
local_root_path: str | None = None
local_root_paths: tuple[str, ...] = ()
@property
def is_local_mode(self) -> bool:

View file

@ -26,13 +26,16 @@ from langchain_core.tools import BaseTool, StructuredTool
from langgraph.types import Command
from sqlalchemy import delete, select
from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.agents.new_chat.middleware.multi_root_local_folder_backend import (
MultiRootLocalFolderBackend,
)
from app.agents.new_chat.sandbox import (
_evict_sandbox_cache,
delete_sandbox,
get_or_create_sandbox,
is_sandbox_enabled,
)
from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.db import Chunk, Document, DocumentType, Folder, shielded_async_session
from app.indexing_pipeline.document_chunker import chunk_text
from app.utils.document_converters import (
@ -222,6 +225,8 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
"\n\n## Local Folder Mode"
"\n\nThis chat is running in desktop local-folder mode."
" Keep all file operations local. Do not use save_document."
" Always use mount-prefixed absolute paths like /<folder>/file.ext."
" If you are unsure which mounts are available, call ls('/') first."
)
super().__init__(
@ -771,12 +776,30 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
"""Only cloud mode persists file content to Document/Chunk tables."""
return self._filesystem_mode == FilesystemMode.CLOUD
@staticmethod
def _get_contract_suggested_path(runtime: ToolRuntime[None, FilesystemState]) -> str:
def _default_mount_prefix(self, runtime: ToolRuntime[None, FilesystemState]) -> str:
backend = self._get_backend(runtime)
if isinstance(backend, MultiRootLocalFolderBackend):
return f"/{backend.default_mount()}"
return ""
def _get_contract_suggested_path(
self, runtime: ToolRuntime[None, FilesystemState]
) -> str:
contract = runtime.state.get("file_operation_contract") or {}
suggested = contract.get("suggested_path")
if isinstance(suggested, str) and suggested.strip():
return suggested.strip()
cleaned = suggested.strip()
if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER:
mount_prefix = self._default_mount_prefix(runtime)
if mount_prefix and cleaned.startswith("/") and not cleaned.startswith(
f"{mount_prefix}/"
):
return f"{mount_prefix}{cleaned}"
return cleaned
if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER:
mount_prefix = self._default_mount_prefix(runtime)
if mount_prefix:
return f"{mount_prefix}/notes.md"
return "/notes.md"
def _resolve_write_target_path(
@ -787,6 +810,20 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
candidate = file_path.strip()
if not candidate:
return self._get_contract_suggested_path(runtime)
if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER:
backend = self._get_backend(runtime)
mount_prefix = self._default_mount_prefix(runtime)
if mount_prefix and not candidate.startswith("/"):
return f"{mount_prefix}/{candidate.lstrip('/')}"
if (
mount_prefix
and isinstance(backend, MultiRootLocalFolderBackend)
and candidate.startswith("/")
):
mount_names = backend.list_mounts()
first_segment = candidate.lstrip("/").split("/", 1)[0]
if first_segment not in mount_names:
return f"{mount_prefix}{candidate}"
if not candidate.startswith("/"):
return f"/{candidate.lstrip('/')}"
return candidate

View file

@ -0,0 +1,328 @@
"""Aggregate multiple LocalFolderBackend roots behind mount-prefixed virtual paths."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
from deepagents.backends.protocol import (
EditResult,
FileDownloadResponse,
FileInfo,
FileUploadResponse,
GrepMatch,
WriteResult,
)
from app.agents.new_chat.middleware.local_folder_backend import LocalFolderBackend
_INVALID_PATH = "invalid_path"
_FILE_NOT_FOUND = "file_not_found"
_IS_DIRECTORY = "is_directory"
class MultiRootLocalFolderBackend:
"""Route filesystem operations to one of several mounted local roots.
Virtual paths are namespaced as:
- `/<mount>/...`
where `<mount>` is derived from each selected root folder name.
"""
def __init__(self, root_paths: tuple[str, ...]) -> None:
if not root_paths:
msg = "At least one local root path is required"
raise ValueError(msg)
self._mount_to_backend: dict[str, LocalFolderBackend] = {}
for raw_root in root_paths:
normalized_root = str(Path(raw_root).expanduser().resolve())
base_mount = Path(normalized_root).name or "root"
mount = base_mount
suffix = 2
while mount in self._mount_to_backend:
mount = f"{base_mount}-{suffix}"
suffix += 1
self._mount_to_backend[mount] = LocalFolderBackend(normalized_root)
self._mount_order = tuple(self._mount_to_backend.keys())
def list_mounts(self) -> tuple[str, ...]:
return self._mount_order
def default_mount(self) -> str:
return self._mount_order[0]
def _mount_error(self) -> str:
mounts = ", ".join(f"/{mount}" for mount in self._mount_order)
return (
"Path must start with one of the selected folders: "
f"{mounts}. Example: /{self._mount_order[0]}/file.txt"
)
def _split_mount_path(self, virtual_path: str) -> tuple[str, str]:
if not virtual_path.startswith("/"):
msg = f"Invalid path (must be absolute): {virtual_path}"
raise ValueError(msg)
rel = virtual_path.lstrip("/")
if not rel:
raise ValueError(self._mount_error())
mount, _, remainder = rel.partition("/")
backend = self._mount_to_backend.get(mount)
if backend is None:
raise ValueError(self._mount_error())
local_path = f"/{remainder}" if remainder else "/"
return mount, local_path
@staticmethod
def _prefix_mount_path(mount: str, local_path: str) -> str:
if local_path == "/":
return f"/{mount}"
return f"/{mount}{local_path}"
@staticmethod
def _get_value(item: Any, key: str) -> Any:
if isinstance(item, dict):
return item.get(key)
return getattr(item, key, None)
@classmethod
def _get_str(cls, item: Any, key: str) -> str:
value = cls._get_value(item, key)
return value if isinstance(value, str) else ""
@classmethod
def _get_int(cls, item: Any, key: str) -> int:
value = cls._get_value(item, key)
return int(value) if isinstance(value, int | float) else 0
@classmethod
def _get_bool(cls, item: Any, key: str) -> bool:
value = cls._get_value(item, key)
return bool(value)
def _list_mount_roots(self) -> list[FileInfo]:
return [
FileInfo(path=f"/{mount}", is_dir=True, size=0, modified_at="0")
for mount in self._mount_order
]
def _transform_infos(self, mount: str, infos: list[FileInfo]) -> list[FileInfo]:
transformed: list[FileInfo] = []
for info in infos:
transformed.append(
FileInfo(
path=self._prefix_mount_path(mount, self._get_str(info, "path")),
is_dir=self._get_bool(info, "is_dir"),
size=self._get_int(info, "size"),
modified_at=self._get_str(info, "modified_at"),
)
)
return transformed
def ls_info(self, path: str) -> list[FileInfo]:
if path == "/":
return self._list_mount_roots()
try:
mount, local_path = self._split_mount_path(path)
except ValueError:
return []
return self._transform_infos(mount, self._mount_to_backend[mount].ls_info(local_path))
async def als_info(self, path: str) -> list[FileInfo]:
return await asyncio.to_thread(self.ls_info, path)
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
try:
mount, local_path = self._split_mount_path(file_path)
except ValueError as exc:
return f"Error: {exc}"
return self._mount_to_backend[mount].read(local_path, offset, limit)
async def aread(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
return await asyncio.to_thread(self.read, file_path, offset, limit)
def read_raw(self, file_path: str) -> str:
try:
mount, local_path = self._split_mount_path(file_path)
except ValueError as exc:
return f"Error: {exc}"
return self._mount_to_backend[mount].read_raw(local_path)
async def aread_raw(self, file_path: str) -> str:
return await asyncio.to_thread(self.read_raw, file_path)
def write(self, file_path: str, content: str) -> WriteResult:
try:
mount, local_path = self._split_mount_path(file_path)
except ValueError as exc:
return WriteResult(error=f"Error: {exc}")
result = self._mount_to_backend[mount].write(local_path, content)
if result.path:
result.path = self._prefix_mount_path(mount, result.path)
return result
async def awrite(self, file_path: str, content: str) -> WriteResult:
return await asyncio.to_thread(self.write, file_path, content)
def edit(
self,
file_path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
) -> EditResult:
try:
mount, local_path = self._split_mount_path(file_path)
except ValueError as exc:
return EditResult(error=f"Error: {exc}")
result = self._mount_to_backend[mount].edit(
local_path, old_string, new_string, replace_all
)
if result.path:
result.path = self._prefix_mount_path(mount, result.path)
return result
async def aedit(
self,
file_path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
) -> EditResult:
return await asyncio.to_thread(
self.edit, file_path, old_string, new_string, replace_all
)
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
if path == "/":
prefixed_results: list[FileInfo] = []
if pattern.startswith("/"):
mount, _, remainder = pattern.lstrip("/").partition("/")
backend = self._mount_to_backend.get(mount)
if not backend:
return []
local_pattern = f"/{remainder}" if remainder else "/"
return self._transform_infos(
mount, backend.glob_info(local_pattern, path="/")
)
for mount, backend in self._mount_to_backend.items():
prefixed_results.extend(
self._transform_infos(mount, backend.glob_info(pattern, path="/"))
)
return prefixed_results
try:
mount, local_path = self._split_mount_path(path)
except ValueError:
return []
return self._transform_infos(
mount, self._mount_to_backend[mount].glob_info(pattern, path=local_path)
)
async def aglob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
return await asyncio.to_thread(self.glob_info, pattern, path)
def grep_raw(
self, pattern: str, path: str | None = None, glob: str | None = None
) -> list[GrepMatch] | str:
if not pattern:
return "Error: pattern cannot be empty"
if path is None or path == "/":
all_matches: list[GrepMatch] = []
for mount, backend in self._mount_to_backend.items():
result = backend.grep_raw(pattern, path="/", glob=glob)
if isinstance(result, str):
return result
all_matches.extend(
[
GrepMatch(
path=self._prefix_mount_path(mount, self._get_str(match, "path")),
line=self._get_int(match, "line"),
text=self._get_str(match, "text"),
)
for match in result
]
)
return all_matches
try:
mount, local_path = self._split_mount_path(path)
except ValueError as exc:
return f"Error: {exc}"
result = self._mount_to_backend[mount].grep_raw(
pattern, path=local_path, glob=glob
)
if isinstance(result, str):
return result
return [
GrepMatch(
path=self._prefix_mount_path(mount, self._get_str(match, "path")),
line=self._get_int(match, "line"),
text=self._get_str(match, "text"),
)
for match in result
]
async def agrep_raw(
self, pattern: str, path: str | None = None, glob: str | None = None
) -> list[GrepMatch] | str:
return await asyncio.to_thread(self.grep_raw, pattern, path, glob)
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
grouped: dict[str, list[tuple[str, bytes]]] = {}
invalid: list[FileUploadResponse] = []
for virtual_path, content in files:
try:
mount, local_path = self._split_mount_path(virtual_path)
except ValueError:
invalid.append(FileUploadResponse(path=virtual_path, error=_INVALID_PATH))
continue
grouped.setdefault(mount, []).append((local_path, content))
responses = list(invalid)
for mount, mount_files in grouped.items():
result = self._mount_to_backend[mount].upload_files(mount_files)
responses.extend(
[
FileUploadResponse(
path=self._prefix_mount_path(mount, self._get_str(item, "path")),
error=self._get_str(item, "error") or None,
)
for item in result
]
)
return responses
async def aupload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
return await asyncio.to_thread(self.upload_files, files)
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
grouped: dict[str, list[str]] = {}
invalid: list[FileDownloadResponse] = []
for virtual_path in paths:
try:
mount, local_path = self._split_mount_path(virtual_path)
except ValueError:
invalid.append(
FileDownloadResponse(path=virtual_path, content=None, error=_INVALID_PATH)
)
continue
grouped.setdefault(mount, []).append(local_path)
responses = list(invalid)
for mount, mount_paths in grouped.items():
result = self._mount_to_backend[mount].download_files(mount_paths)
responses.extend(
[
FileDownloadResponse(
path=self._prefix_mount_path(mount, self._get_str(item, "path")),
content=self._get_value(item, "content"),
error=self._get_str(item, "error") or None,
)
for item in result
]
)
return responses
async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]:
return await asyncio.to_thread(self.download_files, paths)

View file

@ -73,7 +73,7 @@ def _resolve_filesystem_selection(
*,
mode: str,
client_platform: str,
local_root: str | None,
local_roots: list[str] | None,
) -> FilesystemSelection:
"""Validate and normalize filesystem mode settings from request payload."""
try:
@ -96,21 +96,29 @@ def _resolve_filesystem_selection(
status_code=400,
detail="desktop_local_folder mode is only available on desktop runtime.",
)
if not local_root or not local_root.strip():
normalized_roots: list[str] = []
for root in local_roots or []:
trimmed = root.strip()
if trimmed and trimmed not in normalized_roots:
normalized_roots.append(trimmed)
if not normalized_roots:
raise HTTPException(
status_code=400,
detail="local_filesystem_root is required for desktop_local_folder mode.",
detail=(
"local_filesystem_roots must include at least one root for "
"desktop_local_folder mode."
),
)
return FilesystemSelection(
mode=resolved_mode,
client_platform=resolved_platform,
local_root_path=local_root.strip(),
local_root_paths=tuple(normalized_roots),
)
return FilesystemSelection(
mode=FilesystemMode.CLOUD,
client_platform=resolved_platform,
local_root_path=None,
local_root_paths=(),
)
@ -1188,7 +1196,7 @@ async def handle_new_chat(
filesystem_selection = _resolve_filesystem_selection(
mode=request.filesystem_mode,
client_platform=request.client_platform,
local_root=request.local_filesystem_root,
local_roots=request.local_filesystem_roots,
)
# Get search space to check LLM config preferences
@ -1310,7 +1318,7 @@ async def regenerate_response(
filesystem_selection = _resolve_filesystem_selection(
mode=request.filesystem_mode,
client_platform=request.client_platform,
local_root=request.local_filesystem_root,
local_roots=request.local_filesystem_roots,
)
# Get the checkpointer and state history
@ -1569,7 +1577,7 @@ async def resume_chat(
filesystem_selection = _resolve_filesystem_selection(
mode=request.filesystem_mode,
client_platform=request.client_platform,
local_root=request.local_filesystem_root,
local_roots=request.local_filesystem_roots,
)
search_space_result = await session.execute(

View file

@ -186,7 +186,7 @@ class NewChatRequest(BaseModel):
)
filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud"
client_platform: Literal["web", "desktop"] = "web"
local_filesystem_root: str | None = None
local_filesystem_roots: list[str] | None = None
class RegenerateRequest(BaseModel):
@ -209,7 +209,7 @@ class RegenerateRequest(BaseModel):
disabled_tools: list[str] | None = None
filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud"
client_platform: Literal["web", "desktop"] = "web"
local_filesystem_root: str | None = None
local_filesystem_roots: list[str] | None = None
# =============================================================================
@ -235,7 +235,7 @@ class ResumeRequest(BaseModel):
decisions: list[ResumeDecision]
filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud"
client_platform: Literal["web", "desktop"] = "web"
local_filesystem_root: str | None = None
local_filesystem_roots: list[str] | None = None
# =============================================================================

View file

@ -8,7 +8,9 @@ from app.agents.new_chat.filesystem_selection import (
FilesystemMode,
FilesystemSelection,
)
from app.agents.new_chat.middleware.local_folder_backend import LocalFolderBackend
from app.agents.new_chat.middleware.multi_root_local_folder_backend import (
MultiRootLocalFolderBackend,
)
pytestmark = pytest.mark.unit
@ -17,16 +19,16 @@ class _RuntimeStub:
state = {"files": {}}
def test_backend_resolver_returns_local_backend_for_local_mode(tmp_path: Path):
def test_backend_resolver_returns_multi_root_backend_for_single_root(tmp_path: Path):
selection = FilesystemSelection(
mode=FilesystemMode.DESKTOP_LOCAL_FOLDER,
client_platform=ClientPlatform.DESKTOP,
local_root_path=str(tmp_path),
local_root_paths=(str(tmp_path),),
)
resolver = build_backend_resolver(selection)
backend = resolver(_RuntimeStub())
assert isinstance(backend, LocalFolderBackend)
assert isinstance(backend, MultiRootLocalFolderBackend)
def test_backend_resolver_uses_cloud_mode_by_default():
@ -35,3 +37,19 @@ def test_backend_resolver_uses_cloud_mode_by_default():
# StateBackend class name check keeps this test decoupled
# from internal deepagents runtime class identity.
assert backend.__class__.__name__ == "StateBackend"
def test_backend_resolver_returns_multi_root_backend_for_multiple_roots(tmp_path: Path):
root_one = tmp_path / "resume"
root_two = tmp_path / "notes"
root_one.mkdir()
root_two.mkdir()
selection = FilesystemSelection(
mode=FilesystemMode.DESKTOP_LOCAL_FOLDER,
client_platform=ClientPlatform.DESKTOP,
local_root_paths=(str(root_one), str(root_two)),
)
resolver = build_backend_resolver(selection)
backend = resolver(_RuntimeStub())
assert isinstance(backend, MultiRootLocalFolderBackend)