mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat(filesystem): add multi-root local folder support in backend
This commit is contained in:
parent
daac6b5269
commit
6721919398
7 changed files with 422 additions and 30 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue