mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
refactor(agents): group filesystem backends under filesystem/backends/
The concrete filesystem backends are consumed only by the MAC filesystem layer (tools, path-resolution middleware, the resolver, skills backend) and tests -- no external app code. Group them next to the filesystem middleware they serve: - filesystem_backends.py -> filesystem/backends/resolver.py - middleware/kb_postgres_backend.py -> filesystem/backends/kb_postgres.py - middleware/local_folder_backend.py -> filesystem/backends/local_folder.py - middleware/multi_root_local_folder_backend.py -> .../multi_root_local_folder.py - document_xml.py -> filesystem/backends/document_xml.py Repoint all 21 importers. No behavior change; import-all + filesystem backend/path-resolution/knowledge-search unit tests stay green (478).
This commit is contained in:
parent
f615d6b530
commit
21509e7eca
24 changed files with 70 additions and 32 deletions
|
|
@ -12,6 +12,9 @@ from langchain_core.tools import BaseTool
|
|||
from langgraph.types import Checkpointer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.agents.multi_agent_chat.shared.middleware.filesystem.backends.resolver import (
|
||||
build_backend_resolver,
|
||||
)
|
||||
from app.agents.multi_agent_chat.subagents import (
|
||||
get_subagents_to_exclude,
|
||||
main_prompt_registry_subagent_lines,
|
||||
|
|
@ -20,7 +23,6 @@ from app.agents.multi_agent_chat.subagents.mcp_tools.index import (
|
|||
load_mcp_tools_by_connector,
|
||||
)
|
||||
from app.agents.shared.feature_flags import AgentFeatureFlags, get_flags
|
||||
from app.agents.shared.filesystem_backends import build_backend_resolver
|
||||
from app.agents.shared.filesystem_selection import FilesystemMode, FilesystemSelection
|
||||
from app.agents.shared.llm_config import AgentConfig
|
||||
from app.agents.shared.prompt_caching import apply_litellm_prompt_caching
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ from deepagents.backends.state import StateBackend
|
|||
if TYPE_CHECKING:
|
||||
from langchain.tools import ToolRuntime
|
||||
|
||||
from app.agents.shared.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
from app.agents.multi_agent_chat.shared.middleware.filesystem.backends.kb_postgres import (
|
||||
KBPostgresBackend,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -308,7 +310,7 @@ def build_skills_backend_factory(
|
|||
# Imported lazily to avoid a hard dependency at module import time:
|
||||
# ``KBPostgresBackend`` pulls in DB models, which are unnecessary for
|
||||
# the unit-tested builtin path.
|
||||
from app.agents.shared.middleware.kb_postgres_backend import (
|
||||
from app.agents.multi_agent_chat.shared.middleware.filesystem.backends.kb_postgres import (
|
||||
KBPostgresBackend,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
"""Shared XML builder for KB documents.
|
||||
|
||||
Produces the citation-friendly XML used by every read of a knowledge-base
|
||||
document (lazy-loaded by :class:`KBPostgresBackend` and synthetic anonymous
|
||||
files). The XML carries a ``<chunk_index>`` near the top so the LLM can jump
|
||||
directly to matched-chunk line ranges via ``read_file(offset=…, limit=…)``.
|
||||
|
||||
Extracted from the original ``knowledge_search.py`` so the backend, the
|
||||
priority middleware, and any future renderer share a single implementation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_document_xml(
|
||||
document: dict[str, Any],
|
||||
matched_chunk_ids: set[int] | None = None,
|
||||
) -> str:
|
||||
"""Build citation-friendly XML with a ``<chunk_index>`` for smart seeking.
|
||||
|
||||
Args:
|
||||
document: Dict shape produced by hybrid search / lazy-load helpers.
|
||||
Expected keys: ``document`` (with ``id``, ``title``,
|
||||
``document_type``, ``metadata``) and ``chunks``
|
||||
(list of ``{chunk_id, content}``).
|
||||
matched_chunk_ids: Optional set of chunk IDs to flag as
|
||||
``matched="true"`` in the chunk index.
|
||||
"""
|
||||
matched = matched_chunk_ids or set()
|
||||
|
||||
doc_meta = document.get("document") or {}
|
||||
metadata = (doc_meta.get("metadata") or {}) if isinstance(doc_meta, dict) else {}
|
||||
document_id = doc_meta.get("id", document.get("document_id", "unknown"))
|
||||
document_type = doc_meta.get("document_type", document.get("source", "UNKNOWN"))
|
||||
title = doc_meta.get("title") or metadata.get("title") or "Untitled Document"
|
||||
url = (
|
||||
metadata.get("url") or metadata.get("source") or metadata.get("page_url") or ""
|
||||
)
|
||||
metadata_json = json.dumps(metadata, ensure_ascii=False)
|
||||
|
||||
metadata_lines: list[str] = [
|
||||
"<document>",
|
||||
"<document_metadata>",
|
||||
f" <document_id>{document_id}</document_id>",
|
||||
f" <document_type>{document_type}</document_type>",
|
||||
f" <title><![CDATA[{title}]]></title>",
|
||||
f" <url><![CDATA[{url}]]></url>",
|
||||
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>",
|
||||
"</document_metadata>",
|
||||
"",
|
||||
]
|
||||
|
||||
chunks = document.get("chunks") or []
|
||||
chunk_entries: list[tuple[int | None, str]] = []
|
||||
if isinstance(chunks, list):
|
||||
for chunk in chunks:
|
||||
if not isinstance(chunk, dict):
|
||||
continue
|
||||
chunk_id = chunk.get("chunk_id") or chunk.get("id")
|
||||
chunk_content = str(chunk.get("content", "")).strip()
|
||||
if not chunk_content:
|
||||
continue
|
||||
if chunk_id is None:
|
||||
xml = f" <chunk><![CDATA[{chunk_content}]]></chunk>"
|
||||
else:
|
||||
xml = f" <chunk id='{chunk_id}'><![CDATA[{chunk_content}]]></chunk>"
|
||||
chunk_entries.append((chunk_id, xml))
|
||||
|
||||
index_overhead = 1 + len(chunk_entries) + 1 + 1 + 1
|
||||
first_chunk_line = len(metadata_lines) + index_overhead + 1
|
||||
|
||||
current_line = first_chunk_line
|
||||
index_entry_lines: list[str] = []
|
||||
for cid, xml_str in chunk_entries:
|
||||
num_lines = xml_str.count("\n") + 1
|
||||
end_line = current_line + num_lines - 1
|
||||
matched_attr = ' matched="true"' if cid is not None and cid in matched else ""
|
||||
if cid is not None:
|
||||
index_entry_lines.append(
|
||||
f' <entry chunk_id="{cid}" lines="{current_line}-{end_line}"{matched_attr}/>'
|
||||
)
|
||||
else:
|
||||
index_entry_lines.append(
|
||||
f' <entry lines="{current_line}-{end_line}"{matched_attr}/>'
|
||||
)
|
||||
current_line = end_line + 1
|
||||
|
||||
lines = metadata_lines.copy()
|
||||
lines.append("<chunk_index>")
|
||||
lines.extend(index_entry_lines)
|
||||
lines.append("</chunk_index>")
|
||||
lines.append("")
|
||||
lines.append("<document_content>")
|
||||
for _, xml_str in chunk_entries:
|
||||
lines.append(xml_str)
|
||||
lines.extend(["</document_content>", "</document>"])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
__all__ = ["build_document_xml"]
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,613 @@
|
|||
"""Desktop local-folder filesystem backend for deepagents tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fnmatch
|
||||
import os
|
||||
import threading
|
||||
from collections import deque
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from deepagents.backends.protocol import (
|
||||
EditResult,
|
||||
FileDownloadResponse,
|
||||
FileInfo,
|
||||
FileUploadResponse,
|
||||
GrepMatch,
|
||||
WriteResult,
|
||||
)
|
||||
from deepagents.backends.utils import (
|
||||
create_file_data,
|
||||
format_read_response,
|
||||
perform_string_replacement,
|
||||
)
|
||||
|
||||
_INVALID_PATH = "invalid_path"
|
||||
_FILE_NOT_FOUND = "file_not_found"
|
||||
_IS_DIRECTORY = "is_directory"
|
||||
|
||||
|
||||
class LocalFolderBackend:
|
||||
"""Filesystem backend rooted to a single local folder."""
|
||||
|
||||
def __init__(self, root_path: str) -> None:
|
||||
root = Path(root_path).expanduser().resolve()
|
||||
if not root.exists() or not root.is_dir():
|
||||
msg = f"Local filesystem root does not exist or is not a directory: {root_path}"
|
||||
raise ValueError(msg)
|
||||
self._root = root
|
||||
self._locks: dict[str, threading.Lock] = {}
|
||||
self._locks_mu = threading.Lock()
|
||||
|
||||
def _lock_for(self, path: str) -> threading.Lock:
|
||||
with self._locks_mu:
|
||||
if path not in self._locks:
|
||||
self._locks[path] = threading.Lock()
|
||||
return self._locks[path]
|
||||
|
||||
def _resolve_virtual(self, virtual_path: str, *, allow_root: bool = False) -> Path:
|
||||
if not virtual_path.startswith("/"):
|
||||
msg = f"Invalid path (must be absolute): {virtual_path}"
|
||||
raise ValueError(msg)
|
||||
rel = virtual_path.lstrip("/")
|
||||
candidate = self._root if rel == "" else (self._root / rel)
|
||||
resolved = candidate.resolve()
|
||||
if not allow_root and resolved == self._root:
|
||||
msg = "Path must refer to a file or child directory under root"
|
||||
raise ValueError(msg)
|
||||
if not resolved.is_relative_to(self._root):
|
||||
msg = f"Path escapes local filesystem root: {virtual_path}"
|
||||
raise ValueError(msg)
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
def _to_virtual(path: Path, root: Path) -> str:
|
||||
rel = path.relative_to(root).as_posix()
|
||||
return "/" if rel == "." else f"/{rel}"
|
||||
|
||||
def _write_text_atomic(self, path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_path = path.with_suffix(f"{path.suffix}.tmp")
|
||||
temp_path.write_text(content, encoding="utf-8")
|
||||
os.replace(temp_path, path)
|
||||
|
||||
def _acquire_path_locks(self, *paths: str) -> ExitStack:
|
||||
ordered_paths = sorted(set(paths))
|
||||
stack = ExitStack()
|
||||
for path in ordered_paths:
|
||||
stack.enter_context(self._lock_for(path))
|
||||
return stack
|
||||
|
||||
@staticmethod
|
||||
def _clamp_page_size(page_size: int) -> int:
|
||||
return max(1, min(page_size, 1000))
|
||||
|
||||
def _read_dir_entries(self, directory_path: str) -> list[dict[str, Any]]:
|
||||
directory = Path(directory_path)
|
||||
try:
|
||||
children = sorted(
|
||||
directory.iterdir(),
|
||||
key=lambda p: (not p.is_dir(), p.name.lower()),
|
||||
)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
for child in children:
|
||||
try:
|
||||
stat_result = child.stat()
|
||||
except OSError:
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"path": self._to_virtual(child, self._root),
|
||||
"is_dir": child.is_dir(),
|
||||
"size": stat_result.st_size if child.is_file() else 0,
|
||||
"modified_at": str(stat_result.st_mtime),
|
||||
"absolute_path": str(child),
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
def ls_info(self, path: str) -> list[FileInfo]:
|
||||
try:
|
||||
target = self._resolve_virtual(path, allow_root=True)
|
||||
except ValueError:
|
||||
return []
|
||||
if not target.exists() or not target.is_dir():
|
||||
return []
|
||||
infos: list[FileInfo] = []
|
||||
for child in sorted(
|
||||
target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())
|
||||
):
|
||||
infos.append(
|
||||
FileInfo(
|
||||
path=self._to_virtual(child, self._root),
|
||||
is_dir=child.is_dir(),
|
||||
size=child.stat().st_size if child.is_file() else 0,
|
||||
modified_at=str(child.stat().st_mtime),
|
||||
)
|
||||
)
|
||||
return infos
|
||||
|
||||
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:
|
||||
path = self._resolve_virtual(file_path)
|
||||
except ValueError:
|
||||
return f"Error: Invalid path '{file_path}'"
|
||||
if not path.exists():
|
||||
return f"Error: File '{file_path}' not found"
|
||||
if not path.is_file():
|
||||
return f"Error: Path '{file_path}' is not a file"
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
file_data = create_file_data(content)
|
||||
return format_read_response(file_data, 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:
|
||||
"""Read raw file text without line-number formatting."""
|
||||
try:
|
||||
path = self._resolve_virtual(file_path)
|
||||
except ValueError:
|
||||
return f"Error: Invalid path '{file_path}'"
|
||||
if not path.exists():
|
||||
return f"Error: File '{file_path}' not found"
|
||||
if not path.is_file():
|
||||
return f"Error: Path '{file_path}' is not a file"
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
async def aread_raw(self, file_path: str) -> str:
|
||||
"""Async variant of read_raw."""
|
||||
return await asyncio.to_thread(self.read_raw, file_path)
|
||||
|
||||
def write(self, file_path: str, content: str) -> WriteResult:
|
||||
try:
|
||||
path = self._resolve_virtual(file_path)
|
||||
except ValueError:
|
||||
return WriteResult(error=f"Error: Invalid path '{file_path}'")
|
||||
lock = self._lock_for(file_path)
|
||||
with lock:
|
||||
if path.exists():
|
||||
return WriteResult(
|
||||
error=(
|
||||
f"Cannot write to {file_path} because it already exists. "
|
||||
"Read and then make an edit, or write to a new path."
|
||||
)
|
||||
)
|
||||
parent = path.parent
|
||||
if not parent.exists() or not parent.is_dir():
|
||||
return WriteResult(
|
||||
error=(
|
||||
f"Error: parent directory for '{file_path}' does not exist. "
|
||||
"Create the folder first or write to an existing directory."
|
||||
)
|
||||
)
|
||||
self._write_text_atomic(path, content)
|
||||
return WriteResult(path=file_path, files_update=None)
|
||||
|
||||
async def awrite(self, file_path: str, content: str) -> WriteResult:
|
||||
return await asyncio.to_thread(self.write, file_path, content)
|
||||
|
||||
def list_tree(
|
||||
self,
|
||||
path: str = "/",
|
||||
*,
|
||||
max_depth: int | None = 8,
|
||||
page_size: int = 500,
|
||||
include_files: bool = True,
|
||||
include_dirs: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if not include_files and not include_dirs:
|
||||
return {
|
||||
"entries": [],
|
||||
"truncated": False,
|
||||
}
|
||||
|
||||
normalized_depth = None if max_depth is None else max(0, int(max_depth))
|
||||
page_limit = self._clamp_page_size(int(page_size))
|
||||
try:
|
||||
start = self._resolve_virtual(path, allow_root=True)
|
||||
except ValueError:
|
||||
return {"error": f"Error: invalid path '{path}'"}
|
||||
if not start.exists():
|
||||
return {"error": f"Error: path '{path}' not found"}
|
||||
if start.is_file():
|
||||
stat_result = start.stat()
|
||||
if include_files:
|
||||
return {
|
||||
"entries": [
|
||||
{
|
||||
"path": self._to_virtual(start, self._root),
|
||||
"is_dir": False,
|
||||
"size": stat_result.st_size,
|
||||
"modified_at": str(stat_result.st_mtime),
|
||||
"depth": 0,
|
||||
}
|
||||
],
|
||||
"truncated": False,
|
||||
}
|
||||
return {
|
||||
"entries": [],
|
||||
"truncated": False,
|
||||
}
|
||||
|
||||
pending_dirs: deque[tuple[str, int]] = deque([(str(start), 0)])
|
||||
entries: list[dict[str, Any]] = []
|
||||
truncated = False
|
||||
while pending_dirs and not truncated:
|
||||
next_dir_path, next_depth = pending_dirs.popleft()
|
||||
active_entries = self._read_dir_entries(next_dir_path)
|
||||
for item in active_entries:
|
||||
item_depth = next_depth + 1
|
||||
if normalized_depth is not None and item_depth > normalized_depth:
|
||||
continue
|
||||
if item["is_dir"]:
|
||||
if normalized_depth is None or item_depth <= normalized_depth:
|
||||
pending_dirs.append((item["absolute_path"], item_depth))
|
||||
if include_dirs:
|
||||
entries.append(
|
||||
{
|
||||
"path": item["path"],
|
||||
"is_dir": True,
|
||||
"size": 0,
|
||||
"modified_at": item["modified_at"],
|
||||
"depth": item_depth,
|
||||
}
|
||||
)
|
||||
elif include_files:
|
||||
entries.append(
|
||||
{
|
||||
"path": item["path"],
|
||||
"is_dir": False,
|
||||
"size": item["size"],
|
||||
"modified_at": item["modified_at"],
|
||||
"depth": item_depth,
|
||||
}
|
||||
)
|
||||
if len(entries) >= page_limit:
|
||||
truncated = True
|
||||
break
|
||||
|
||||
return {
|
||||
"entries": entries,
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
async def alist_tree(
|
||||
self,
|
||||
path: str = "/",
|
||||
*,
|
||||
max_depth: int | None = 8,
|
||||
page_size: int = 500,
|
||||
include_files: bool = True,
|
||||
include_dirs: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
return await asyncio.to_thread(
|
||||
self.list_tree,
|
||||
path,
|
||||
max_depth=max_depth,
|
||||
page_size=page_size,
|
||||
include_files=include_files,
|
||||
include_dirs=include_dirs,
|
||||
)
|
||||
|
||||
def move(
|
||||
self,
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
overwrite: bool = False,
|
||||
) -> WriteResult:
|
||||
try:
|
||||
source = self._resolve_virtual(source_path)
|
||||
destination = self._resolve_virtual(destination_path)
|
||||
except ValueError:
|
||||
return WriteResult(
|
||||
error=(
|
||||
f"Error: invalid source '{source_path}' or destination "
|
||||
f"'{destination_path}' path"
|
||||
)
|
||||
)
|
||||
if source == destination:
|
||||
return WriteResult(error="Error: source and destination paths are the same")
|
||||
with self._acquire_path_locks(source_path, destination_path):
|
||||
if not source.exists():
|
||||
return WriteResult(
|
||||
error=f"Error: source path '{source_path}' not found"
|
||||
)
|
||||
if destination.exists():
|
||||
if not overwrite:
|
||||
return WriteResult(
|
||||
error=(
|
||||
f"Error: destination path '{destination_path}' already exists. "
|
||||
"Set overwrite=True to replace files."
|
||||
)
|
||||
)
|
||||
if source.is_dir() or destination.is_dir():
|
||||
return WriteResult(
|
||||
error=(
|
||||
"Error: overwrite=True is only supported for file-to-file moves."
|
||||
)
|
||||
)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
if overwrite:
|
||||
os.replace(source, destination)
|
||||
else:
|
||||
source.rename(destination)
|
||||
except OSError as exc:
|
||||
return WriteResult(
|
||||
error=f"Error: failed to move '{source_path}': {exc}"
|
||||
)
|
||||
return WriteResult(
|
||||
path=self._to_virtual(destination, self._root), files_update=None
|
||||
)
|
||||
|
||||
async def amove(
|
||||
self,
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
overwrite: bool = False,
|
||||
) -> WriteResult:
|
||||
return await asyncio.to_thread(
|
||||
self.move, source_path, destination_path, overwrite
|
||||
)
|
||||
|
||||
def delete_file(self, file_path: str) -> WriteResult:
|
||||
"""Hard-delete a single file under root.
|
||||
|
||||
Refuses directories, root, and missing paths. Roughly mirrors POSIX
|
||||
``rm path``; ``-r`` recursion and glob expansion are explicitly
|
||||
out of scope.
|
||||
"""
|
||||
try:
|
||||
path = self._resolve_virtual(file_path)
|
||||
except ValueError:
|
||||
return WriteResult(error=f"Error: Invalid path '{file_path}'")
|
||||
with self._lock_for(file_path):
|
||||
if not path.exists():
|
||||
return WriteResult(error=f"Error: File '{file_path}' not found")
|
||||
if path.is_dir():
|
||||
return WriteResult(
|
||||
error=(
|
||||
f"Error: '{file_path}' is a directory. "
|
||||
"Use rmdir for empty directories."
|
||||
)
|
||||
)
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError as exc:
|
||||
return WriteResult(
|
||||
error=f"Error: failed to delete '{file_path}': {exc}"
|
||||
)
|
||||
return WriteResult(path=file_path, files_update=None)
|
||||
|
||||
async def adelete_file(self, file_path: str) -> WriteResult:
|
||||
return await asyncio.to_thread(self.delete_file, file_path)
|
||||
|
||||
def rmdir(self, dir_path: str) -> WriteResult:
|
||||
"""Hard-delete an empty directory under root.
|
||||
|
||||
Refuses files, root, missing paths, and non-empty directories.
|
||||
``os.rmdir`` is naturally empty-only; we pre-check so the error is
|
||||
clearer for the agent.
|
||||
"""
|
||||
try:
|
||||
path = self._resolve_virtual(dir_path)
|
||||
except ValueError:
|
||||
return WriteResult(error=f"Error: Invalid path '{dir_path}'")
|
||||
with self._lock_for(dir_path):
|
||||
if not path.exists():
|
||||
return WriteResult(error=f"Error: Directory '{dir_path}' not found")
|
||||
if not path.is_dir():
|
||||
return WriteResult(error=f"Error: '{dir_path}' is not a directory")
|
||||
try:
|
||||
next(path.iterdir())
|
||||
except StopIteration:
|
||||
pass
|
||||
else:
|
||||
return WriteResult(
|
||||
error=(
|
||||
f"Error: directory '{dir_path}' is not empty. "
|
||||
"Remove its contents first."
|
||||
)
|
||||
)
|
||||
try:
|
||||
os.rmdir(path)
|
||||
except OSError as exc:
|
||||
return WriteResult(error=f"Error: failed to rmdir '{dir_path}': {exc}")
|
||||
return WriteResult(path=dir_path, files_update=None)
|
||||
|
||||
async def armdir(self, dir_path: str) -> WriteResult:
|
||||
return await asyncio.to_thread(self.rmdir, dir_path)
|
||||
|
||||
def edit(
|
||||
self,
|
||||
file_path: str,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
replace_all: bool = False,
|
||||
) -> EditResult:
|
||||
try:
|
||||
path = self._resolve_virtual(file_path)
|
||||
except ValueError:
|
||||
return EditResult(error=f"Error: Invalid path '{file_path}'")
|
||||
lock = self._lock_for(file_path)
|
||||
with lock:
|
||||
if not path.exists() or not path.is_file():
|
||||
return EditResult(error=f"Error: File '{file_path}' not found")
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
result = perform_string_replacement(
|
||||
content, old_string, new_string, replace_all
|
||||
)
|
||||
if isinstance(result, str):
|
||||
return EditResult(error=result)
|
||||
updated_content, occurrences = result
|
||||
self._write_text_atomic(path, updated_content)
|
||||
return EditResult(
|
||||
path=file_path, files_update=None, occurrences=int(occurrences)
|
||||
)
|
||||
|
||||
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]:
|
||||
try:
|
||||
base = self._resolve_virtual(path, allow_root=True)
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
if pattern.startswith("/"):
|
||||
search_base = self._root
|
||||
normalized_pattern = pattern.lstrip("/")
|
||||
else:
|
||||
search_base = base
|
||||
normalized_pattern = pattern
|
||||
|
||||
matches: list[FileInfo] = []
|
||||
for hit in search_base.glob(normalized_pattern):
|
||||
try:
|
||||
resolved = hit.resolve()
|
||||
if not resolved.is_relative_to(self._root):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
matches.append(
|
||||
FileInfo(
|
||||
path=self._to_virtual(resolved, self._root),
|
||||
is_dir=resolved.is_dir(),
|
||||
size=resolved.stat().st_size if resolved.is_file() else 0,
|
||||
modified_at=str(resolved.stat().st_mtime),
|
||||
)
|
||||
)
|
||||
return matches
|
||||
|
||||
async def aglob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
|
||||
return await asyncio.to_thread(self.glob_info, pattern, path)
|
||||
|
||||
def _iter_candidate_files(self, path: str | None, glob: str | None) -> list[Path]:
|
||||
base_virtual = path or "/"
|
||||
try:
|
||||
base = self._resolve_virtual(base_virtual, allow_root=True)
|
||||
except ValueError:
|
||||
return []
|
||||
if not base.exists():
|
||||
return []
|
||||
|
||||
candidates = [p for p in base.rglob("*") if p.is_file()]
|
||||
if glob:
|
||||
candidates = [
|
||||
p
|
||||
for p in candidates
|
||||
if fnmatch.fnmatch(self._to_virtual(p, self._root), glob)
|
||||
or fnmatch.fnmatch(p.name, glob)
|
||||
]
|
||||
return candidates
|
||||
|
||||
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"
|
||||
matches: list[GrepMatch] = []
|
||||
for file_path in self._iter_candidate_files(path, glob):
|
||||
try:
|
||||
lines = file_path.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
).splitlines()
|
||||
except Exception:
|
||||
continue
|
||||
for idx, line in enumerate(lines, start=1):
|
||||
if pattern in line:
|
||||
matches.append(
|
||||
GrepMatch(
|
||||
path=self._to_virtual(file_path, self._root),
|
||||
line=idx,
|
||||
text=line,
|
||||
)
|
||||
)
|
||||
return matches
|
||||
|
||||
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]:
|
||||
responses: list[FileUploadResponse] = []
|
||||
for virtual_path, content in files:
|
||||
try:
|
||||
target = self._resolve_virtual(virtual_path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_path = target.with_suffix(f"{target.suffix}.tmp")
|
||||
temp_path.write_bytes(content)
|
||||
os.replace(temp_path, target)
|
||||
responses.append(FileUploadResponse(path=virtual_path, error=None))
|
||||
except FileNotFoundError:
|
||||
responses.append(
|
||||
FileUploadResponse(path=virtual_path, error=_FILE_NOT_FOUND)
|
||||
)
|
||||
except IsADirectoryError:
|
||||
responses.append(
|
||||
FileUploadResponse(path=virtual_path, error=_IS_DIRECTORY)
|
||||
)
|
||||
except Exception:
|
||||
responses.append(
|
||||
FileUploadResponse(path=virtual_path, error=_INVALID_PATH)
|
||||
)
|
||||
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]:
|
||||
responses: list[FileDownloadResponse] = []
|
||||
for virtual_path in paths:
|
||||
try:
|
||||
target = self._resolve_virtual(virtual_path)
|
||||
if not target.exists():
|
||||
responses.append(
|
||||
FileDownloadResponse(
|
||||
path=virtual_path, content=None, error=_FILE_NOT_FOUND
|
||||
)
|
||||
)
|
||||
continue
|
||||
if target.is_dir():
|
||||
responses.append(
|
||||
FileDownloadResponse(
|
||||
path=virtual_path, content=None, error=_IS_DIRECTORY
|
||||
)
|
||||
)
|
||||
continue
|
||||
responses.append(
|
||||
FileDownloadResponse(
|
||||
path=virtual_path, content=target.read_bytes(), error=None
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
responses.append(
|
||||
FileDownloadResponse(
|
||||
path=virtual_path, content=None, error=_INVALID_PATH
|
||||
)
|
||||
)
|
||||
return responses
|
||||
|
||||
async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
||||
return await asyncio.to_thread(self.download_files, paths)
|
||||
|
|
@ -0,0 +1,491 @@
|
|||
"""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.multi_agent_chat.shared.middleware.filesystem.backends.local_folder 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, mounts: tuple[tuple[str, str], ...]) -> None:
|
||||
if not mounts:
|
||||
msg = "At least one local mount is required"
|
||||
raise ValueError(msg)
|
||||
self._mount_to_backend: dict[str, LocalFolderBackend] = {}
|
||||
for raw_mount, raw_root in mounts:
|
||||
mount = raw_mount.strip()
|
||||
if not mount:
|
||||
msg = "Mount id cannot be empty"
|
||||
raise ValueError(msg)
|
||||
if mount in self._mount_to_backend:
|
||||
msg = f"Duplicate mount id: {mount}"
|
||||
raise ValueError(msg)
|
||||
normalized_root = str(Path(raw_root).expanduser().resolve())
|
||||
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 list_tree(
|
||||
self,
|
||||
path: str = "/",
|
||||
*,
|
||||
max_depth: int | None = 8,
|
||||
page_size: int = 500,
|
||||
include_files: bool = True,
|
||||
include_dirs: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if path == "/":
|
||||
entries = [
|
||||
{
|
||||
"path": f"/{mount}",
|
||||
"is_dir": True,
|
||||
"size": 0,
|
||||
"modified_at": "0",
|
||||
"depth": 0,
|
||||
}
|
||||
for mount in self._mount_order
|
||||
]
|
||||
return {
|
||||
"entries": entries if include_dirs else [],
|
||||
"truncated": False,
|
||||
}
|
||||
|
||||
try:
|
||||
mount, local_path = self._split_mount_path(path)
|
||||
except ValueError as exc:
|
||||
return {"error": f"Error: {exc}"}
|
||||
|
||||
result = self._mount_to_backend[mount].list_tree(
|
||||
local_path,
|
||||
max_depth=max_depth,
|
||||
page_size=page_size,
|
||||
include_files=include_files,
|
||||
include_dirs=include_dirs,
|
||||
)
|
||||
if result.get("error"):
|
||||
return result
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
for entry in result.get("entries", []):
|
||||
raw_path = self._get_str(entry, "path")
|
||||
entries.append(
|
||||
{
|
||||
"path": self._prefix_mount_path(mount, raw_path),
|
||||
"is_dir": self._get_bool(entry, "is_dir"),
|
||||
"size": self._get_int(entry, "size"),
|
||||
"modified_at": self._get_str(entry, "modified_at"),
|
||||
"depth": self._get_int(entry, "depth"),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"entries": entries,
|
||||
"truncated": self._get_bool(result, "truncated"),
|
||||
}
|
||||
|
||||
async def alist_tree(
|
||||
self,
|
||||
path: str = "/",
|
||||
*,
|
||||
max_depth: int | None = 8,
|
||||
page_size: int = 500,
|
||||
include_files: bool = True,
|
||||
include_dirs: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
return await asyncio.to_thread(
|
||||
self.list_tree,
|
||||
path,
|
||||
max_depth=max_depth,
|
||||
page_size=page_size,
|
||||
include_files=include_files,
|
||||
include_dirs=include_dirs,
|
||||
)
|
||||
|
||||
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 move(
|
||||
self,
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
overwrite: bool = False,
|
||||
) -> WriteResult:
|
||||
try:
|
||||
source_mount, source_local_path = self._split_mount_path(source_path)
|
||||
destination_mount, destination_local_path = self._split_mount_path(
|
||||
destination_path
|
||||
)
|
||||
except ValueError as exc:
|
||||
return WriteResult(error=f"Error: {exc}")
|
||||
if source_mount != destination_mount:
|
||||
return WriteResult(
|
||||
error=(
|
||||
"Error: cross-mount moves are not supported. "
|
||||
"Source and destination must be under the same mounted root."
|
||||
)
|
||||
)
|
||||
result = self._mount_to_backend[source_mount].move(
|
||||
source_local_path,
|
||||
destination_local_path,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
if result.path:
|
||||
result.path = self._prefix_mount_path(source_mount, result.path)
|
||||
return result
|
||||
|
||||
async def amove(
|
||||
self,
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
overwrite: bool = False,
|
||||
) -> WriteResult:
|
||||
return await asyncio.to_thread(
|
||||
self.move,
|
||||
source_path,
|
||||
destination_path,
|
||||
overwrite,
|
||||
)
|
||||
|
||||
def delete_file(self, file_path: 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].delete_file(local_path)
|
||||
if result.path:
|
||||
result.path = self._prefix_mount_path(mount, result.path)
|
||||
return result
|
||||
|
||||
async def adelete_file(self, file_path: str) -> WriteResult:
|
||||
return await asyncio.to_thread(self.delete_file, file_path)
|
||||
|
||||
def rmdir(self, dir_path: str) -> WriteResult:
|
||||
try:
|
||||
mount, local_path = self._split_mount_path(dir_path)
|
||||
except ValueError as exc:
|
||||
return WriteResult(error=f"Error: {exc}")
|
||||
if local_path == "/":
|
||||
return WriteResult(error=f"Error: cannot rmdir mount root '{dir_path}'")
|
||||
result = self._mount_to_backend[mount].rmdir(local_path)
|
||||
if result.path:
|
||||
result.path = self._prefix_mount_path(mount, result.path)
|
||||
return result
|
||||
|
||||
async def armdir(self, dir_path: str) -> WriteResult:
|
||||
return await asyncio.to_thread(self.rmdir, dir_path)
|
||||
|
||||
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)
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
"""Filesystem backend resolver for cloud and desktop-local modes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
|
||||
from deepagents.backends.protocol import BackendProtocol
|
||||
from deepagents.backends.state import StateBackend
|
||||
from langgraph.prebuilt.tool_node import ToolRuntime
|
||||
|
||||
from app.agents.multi_agent_chat.shared.middleware.filesystem.backends.kb_postgres import (
|
||||
KBPostgresBackend,
|
||||
)
|
||||
from app.agents.multi_agent_chat.shared.middleware.filesystem.backends.multi_root_local_folder import (
|
||||
MultiRootLocalFolderBackend,
|
||||
)
|
||||
from app.agents.shared.filesystem_selection import FilesystemMode, FilesystemSelection
|
||||
|
||||
|
||||
@lru_cache(maxsize=64)
|
||||
def _cached_multi_root_backend(
|
||||
mounts: tuple[tuple[str, str], ...],
|
||||
) -> MultiRootLocalFolderBackend:
|
||||
return MultiRootLocalFolderBackend(mounts)
|
||||
|
||||
|
||||
def build_backend_resolver(
|
||||
selection: FilesystemSelection,
|
||||
*,
|
||||
search_space_id: int | None = None,
|
||||
) -> Callable[[ToolRuntime], BackendProtocol]:
|
||||
"""Create deepagents backend resolver for the selected filesystem mode.
|
||||
|
||||
In cloud mode the resolver returns a fresh :class:`KBPostgresBackend`
|
||||
bound to the current ``runtime`` so the backend can read staging state
|
||||
(``staged_dirs``, ``pending_moves``, ``files`` cache, ``kb_anon_doc``,
|
||||
``kb_matched_chunk_ids``) for each tool call. When no ``search_space_id``
|
||||
is provided, the resolver falls back to :class:`StateBackend` (used by
|
||||
sub-agents and tests that don't need DB-backed reads).
|
||||
|
||||
Desktop-local mode unchanged.
|
||||
"""
|
||||
|
||||
if selection.mode == FilesystemMode.DESKTOP_LOCAL_FOLDER and selection.local_mounts:
|
||||
|
||||
def _resolve_local(_runtime: ToolRuntime) -> MultiRootLocalFolderBackend:
|
||||
mounts = tuple(
|
||||
(entry.mount_id, entry.root_path) for entry in selection.local_mounts
|
||||
)
|
||||
return _cached_multi_root_backend(mounts)
|
||||
|
||||
return _resolve_local
|
||||
|
||||
if search_space_id is not None:
|
||||
|
||||
def _resolve_kb(runtime: ToolRuntime) -> BackendProtocol:
|
||||
return KBPostgresBackend(search_space_id, runtime)
|
||||
|
||||
return _resolve_kb
|
||||
|
||||
def _resolve_state(runtime: ToolRuntime) -> StateBackend:
|
||||
return StateBackend(runtime)
|
||||
|
||||
return _resolve_state
|
||||
|
|
@ -7,13 +7,13 @@ from typing import TYPE_CHECKING
|
|||
|
||||
from langchain.tools import ToolRuntime
|
||||
|
||||
from app.agents.multi_agent_chat.shared.middleware.filesystem.backends.multi_root_local_folder import (
|
||||
MultiRootLocalFolderBackend,
|
||||
)
|
||||
from app.agents.multi_agent_chat.shared.state.filesystem_state import (
|
||||
SurfSenseFilesystemState,
|
||||
)
|
||||
from app.agents.shared.filesystem_selection import FilesystemMode
|
||||
from app.agents.shared.middleware.multi_root_local_folder_backend import (
|
||||
MultiRootLocalFolderBackend,
|
||||
)
|
||||
|
||||
from ..shared.paths import (
|
||||
extract_mount_from_path,
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@ from langchain_core.messages import ToolMessage
|
|||
from langchain_core.tools import BaseTool, StructuredTool
|
||||
from langgraph.types import Command
|
||||
|
||||
from app.agents.multi_agent_chat.shared.middleware.filesystem.backends.kb_postgres import (
|
||||
KBPostgresBackend,
|
||||
)
|
||||
from app.agents.multi_agent_chat.shared.state.filesystem_state import (
|
||||
SurfSenseFilesystemState,
|
||||
)
|
||||
from app.agents.shared.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
|
||||
from ...middleware.async_dispatch import run_async_blocking
|
||||
from ...middleware.mode import is_cloud
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ from deepagents.backends.utils import validate_path
|
|||
from langchain.tools import ToolRuntime
|
||||
from langchain_core.tools import BaseTool, StructuredTool
|
||||
|
||||
from app.agents.multi_agent_chat.shared.middleware.filesystem.backends.kb_postgres import (
|
||||
KBPostgresBackend,
|
||||
)
|
||||
from app.agents.multi_agent_chat.shared.state.filesystem_state import (
|
||||
SurfSenseFilesystemState,
|
||||
)
|
||||
from app.agents.shared.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
|
||||
from ...middleware.async_dispatch import run_async_blocking
|
||||
from ...middleware.path_resolution import resolve_list_target_path
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ from deepagents.backends.utils import validate_path
|
|||
from langchain.tools import ToolRuntime
|
||||
from langchain_core.tools import BaseTool, StructuredTool
|
||||
|
||||
from app.agents.multi_agent_chat.shared.middleware.filesystem.backends.kb_postgres import (
|
||||
paginate_listing,
|
||||
)
|
||||
from app.agents.multi_agent_chat.shared.state.filesystem_state import (
|
||||
SurfSenseFilesystemState,
|
||||
)
|
||||
from app.agents.shared.middleware.kb_postgres_backend import paginate_listing
|
||||
|
||||
from ...middleware.async_dispatch import run_async_blocking
|
||||
from ...middleware.path_resolution import resolve_list_target_path
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ from langchain.tools import ToolRuntime
|
|||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.types import Command
|
||||
|
||||
from app.agents.multi_agent_chat.shared.middleware.filesystem.backends.kb_postgres import (
|
||||
KBPostgresBackend,
|
||||
)
|
||||
from app.agents.multi_agent_chat.shared.state.filesystem_state import (
|
||||
SurfSenseFilesystemState,
|
||||
)
|
||||
from app.agents.multi_agent_chat.shared.state.reducers import _CLEAR
|
||||
from app.agents.shared.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
from app.agents.shared.path_resolver import DOCUMENTS_ROOT
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ from langchain_core.messages import ToolMessage
|
|||
from langchain_core.tools import BaseTool, StructuredTool
|
||||
from langgraph.types import Command
|
||||
|
||||
from app.agents.multi_agent_chat.shared.middleware.filesystem.backends.kb_postgres import (
|
||||
KBPostgresBackend,
|
||||
)
|
||||
from app.agents.multi_agent_chat.shared.state.filesystem_state import (
|
||||
SurfSenseFilesystemState,
|
||||
)
|
||||
from app.agents.shared.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
|
||||
from ...middleware.async_dispatch import run_async_blocking
|
||||
from ...middleware.path_resolution import resolve_relative
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@ from langchain.tools import ToolRuntime
|
|||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.types import Command
|
||||
|
||||
from app.agents.multi_agent_chat.shared.middleware.filesystem.backends.kb_postgres import (
|
||||
KBPostgresBackend,
|
||||
)
|
||||
from app.agents.multi_agent_chat.shared.state.filesystem_state import (
|
||||
SurfSenseFilesystemState,
|
||||
)
|
||||
from app.agents.multi_agent_chat.shared.state.reducers import _CLEAR
|
||||
from app.agents.shared.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
from app.agents.shared.path_resolver import DOCUMENTS_ROOT
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
|
|||
|
|
@ -13,11 +13,13 @@ from langchain.tools import ToolRuntime
|
|||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.types import Command
|
||||
|
||||
from app.agents.multi_agent_chat.shared.middleware.filesystem.backends.kb_postgres import (
|
||||
KBPostgresBackend,
|
||||
)
|
||||
from app.agents.multi_agent_chat.shared.state.filesystem_state import (
|
||||
SurfSenseFilesystemState,
|
||||
)
|
||||
from app.agents.multi_agent_chat.shared.state.reducers import _CLEAR
|
||||
from app.agents.shared.middleware.kb_postgres_backend import KBPostgresBackend
|
||||
from app.agents.shared.path_resolver import DOCUMENTS_ROOT
|
||||
|
||||
from ...middleware.path_resolution import current_cwd
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue