feat(filesystem): refactor local filesystem handling to use mounts instead of root paths, enhancing mount management and path normalization

This commit is contained in:
Anish Sarkar 2026-04-24 05:59:21 +05:30
parent a7a758f26e
commit 30b55a9baa
16 changed files with 421 additions and 80 deletions

View file

@ -16,9 +16,9 @@ from app.agents.new_chat.middleware.multi_root_local_folder_backend import (
@lru_cache(maxsize=64) @lru_cache(maxsize=64)
def _cached_multi_root_backend( def _cached_multi_root_backend(
root_paths: tuple[str, ...], mounts: tuple[tuple[str, str], ...],
) -> MultiRootLocalFolderBackend: ) -> MultiRootLocalFolderBackend:
return MultiRootLocalFolderBackend(root_paths) return MultiRootLocalFolderBackend(mounts)
def build_backend_resolver( def build_backend_resolver(
@ -26,10 +26,13 @@ def build_backend_resolver(
) -> Callable[[ToolRuntime], StateBackend | MultiRootLocalFolderBackend]: ) -> Callable[[ToolRuntime], StateBackend | MultiRootLocalFolderBackend]:
"""Create deepagents backend resolver for the selected filesystem mode.""" """Create deepagents backend resolver for the selected filesystem mode."""
if selection.mode == FilesystemMode.DESKTOP_LOCAL_FOLDER and selection.local_root_paths: if selection.mode == FilesystemMode.DESKTOP_LOCAL_FOLDER and selection.local_mounts:
def _resolve_local(_runtime: ToolRuntime) -> MultiRootLocalFolderBackend: def _resolve_local(_runtime: ToolRuntime) -> MultiRootLocalFolderBackend:
return _cached_multi_root_backend(selection.local_root_paths) mounts = tuple(
(entry.mount_id, entry.root_path) for entry in selection.local_mounts
)
return _cached_multi_root_backend(mounts)
return _resolve_local return _resolve_local

View file

@ -20,13 +20,21 @@ class ClientPlatform(StrEnum):
DESKTOP = "desktop" DESKTOP = "desktop"
@dataclass(slots=True)
class LocalFilesystemMount:
"""Canonical mount mapping provided by desktop runtime."""
mount_id: str
root_path: str
@dataclass(slots=True) @dataclass(slots=True)
class FilesystemSelection: class FilesystemSelection:
"""Resolved filesystem selection for a single chat request.""" """Resolved filesystem selection for a single chat request."""
mode: FilesystemMode = FilesystemMode.CLOUD mode: FilesystemMode = FilesystemMode.CLOUD
client_platform: ClientPlatform = ClientPlatform.WEB client_platform: ClientPlatform = ClientPlatform.WEB
local_root_paths: tuple[str, ...] = () local_mounts: tuple[LocalFilesystemMount, ...] = ()
@property @property
def is_local_mode(self) -> bool: def is_local_mode(self) -> bool:

View file

@ -48,6 +48,20 @@ class FileIntentPlan(BaseModel):
default=None, default=None,
description="Optional filename (e.g. notes.md) inferred from user request.", description="Optional filename (e.g. notes.md) inferred from user request.",
) )
suggested_directory: str | None = Field(
default=None,
description=(
"Optional directory path (e.g. /reports/q2 or reports/q2) inferred from "
"user request."
),
)
suggested_path: str | None = Field(
default=None,
description=(
"Optional full file path (e.g. /reports/q2/summary.md). If present, this "
"takes precedence over suggested_directory + suggested_filename."
),
)
def _extract_text_from_message(message: BaseMessage) -> str: def _extract_text_from_message(message: BaseMessage) -> str:
@ -88,6 +102,13 @@ def _sanitize_filename(value: str) -> str:
return name return name
def _sanitize_path_segment(value: str) -> str:
segment = re.sub(r"[\\/:*?\"<>|]+", "_", value).strip()
segment = re.sub(r"\s+", "_", segment)
segment = segment.strip("._-")
return segment
def _infer_text_file_extension(user_text: str) -> str: def _infer_text_file_extension(user_text: str) -> str:
lowered = user_text.lower() lowered = user_text.lower()
if any(token in lowered for token in ("json", ".json")): if any(token in lowered for token in ("json", ".json")):
@ -119,29 +140,102 @@ def _infer_text_file_extension(user_text: str) -> str:
return ".md" return ".md"
def _fallback_path(suggested_filename: str | None, *, user_text: str) -> str: def _normalize_directory(value: str) -> str:
raw = value.strip().replace("\\", "/")
raw = raw.strip("/")
if not raw:
return ""
parts = [_sanitize_path_segment(part) for part in raw.split("/") if part.strip()]
parts = [part for part in parts if part]
return "/".join(parts)
def _normalize_file_path(value: str) -> str:
raw = value.strip().replace("\\", "/").strip()
if not raw:
return ""
had_trailing_slash = raw.endswith("/")
raw = raw.strip("/")
if not raw:
return ""
parts = [_sanitize_path_segment(part) for part in raw.split("/") if part.strip()]
parts = [part for part in parts if part]
if not parts:
return ""
if had_trailing_slash:
return f"/{'/'.join(parts)}/"
return f"/{'/'.join(parts)}"
def _infer_directory_from_user_text(user_text: str) -> str | None:
patterns = (
r"\b(?:in|inside|under)\s+(?:the\s+)?([a-zA-Z0-9 _\-/]+?)\s+folder\b",
r"\b(?:in|inside|under)\s+([a-zA-Z0-9 _\-/]+?)\b",
)
lowered = user_text.lower()
for pattern in patterns:
match = re.search(pattern, lowered, flags=re.IGNORECASE)
if not match:
continue
candidate = match.group(1).strip()
if candidate in {"the", "a", "an"}:
continue
normalized = _normalize_directory(candidate)
if normalized:
return normalized
return None
def _fallback_path(
suggested_filename: str | None,
*,
suggested_directory: str | None = None,
suggested_path: str | None = None,
user_text: str,
) -> str:
default_extension = _infer_text_file_extension(user_text) default_extension = _infer_text_file_extension(user_text)
inferred_dir = _infer_directory_from_user_text(user_text)
sanitized_filename = ""
if suggested_filename: if suggested_filename:
sanitized = _sanitize_filename(suggested_filename) sanitized_filename = _sanitize_filename(suggested_filename)
if sanitized.lower().endswith(".txt"): if sanitized_filename.lower().endswith(".txt"):
sanitized = f"{sanitized[:-4]}.md" sanitized_filename = f"{sanitized_filename[:-4]}.md"
if "." not in sanitized: if not sanitized_filename:
sanitized = f"{sanitized}{default_extension}" sanitized_filename = f"notes{default_extension}"
return f"/{sanitized}" elif "." not in sanitized_filename:
return f"/notes{default_extension}" sanitized_filename = f"{sanitized_filename}{default_extension}"
normalized_suggested_path = (
_normalize_file_path(suggested_path) if suggested_path else ""
)
if normalized_suggested_path:
if normalized_suggested_path.endswith("/"):
return f"{normalized_suggested_path.rstrip('/')}/{sanitized_filename}"
return normalized_suggested_path
directory = _normalize_directory(suggested_directory or "")
if not directory and inferred_dir:
directory = inferred_dir
if directory:
return f"/{directory}/{sanitized_filename}"
return f"/{sanitized_filename}"
def _build_classifier_prompt(*, recent_conversation: str, user_text: str) -> str: def _build_classifier_prompt(*, recent_conversation: str, user_text: str) -> str:
return ( return (
"Classify the latest user request into a filesystem intent for an AI agent.\n" "Classify the latest user request into a filesystem intent for an AI agent.\n"
"Return JSON only with this exact schema:\n" "Return JSON only with this exact schema:\n"
'{"intent":"chat_only|file_write|file_read","confidence":0.0,"suggested_filename":"string or null"}\n\n' '{"intent":"chat_only|file_write|file_read","confidence":0.0,"suggested_filename":"string or null","suggested_directory":"string or null","suggested_path":"string or null"}\n\n'
"Rules:\n" "Rules:\n"
"- Use semantic intent, not literal keywords.\n" "- Use semantic intent, not literal keywords.\n"
"- file_write: user asks to create/save/write/update/edit content as a file.\n" "- file_write: user asks to create/save/write/update/edit content as a file.\n"
"- file_read: user asks to open/read/list/search existing files.\n" "- file_read: user asks to open/read/list/search existing files.\n"
"- chat_only: conversational/analysis responses without required file operations.\n" "- chat_only: conversational/analysis responses without required file operations.\n"
"- For file_write, choose a concise semantic suggested_filename and match the requested format.\n" "- For file_write, choose a concise semantic suggested_filename and match the requested format.\n"
"- If the user mentions a folder/directory, populate suggested_directory.\n"
"- If user specifies an explicit full path, populate suggested_path.\n"
"- Use extensions that match user intent (e.g. .md, .json, .yaml, .csv, .py, .ts, .js, .html, .css, .sql).\n" "- Use extensions that match user intent (e.g. .md, .json, .yaml, .csv, .py, .ts, .js, .html, .css, .sql).\n"
"- Do not use .txt; prefer .md for generic text notes.\n" "- Do not use .txt; prefer .md for generic text notes.\n"
"- Do not include dates or timestamps in suggested_filename unless explicitly requested.\n" "- Do not include dates or timestamps in suggested_filename unless explicitly requested.\n"
@ -217,7 +311,12 @@ class FileIntentMiddleware(AgentMiddleware): # type: ignore[type-arg]
return None return None
plan = await self._classify_intent(messages=messages, user_text=user_text) plan = await self._classify_intent(messages=messages, user_text=user_text)
suggested_path = _fallback_path(plan.suggested_filename, user_text=user_text) suggested_path = _fallback_path(
plan.suggested_filename,
suggested_directory=plan.suggested_directory,
suggested_path=plan.suggested_path,
user_text=user_text,
)
contract = { contract = {
"intent": plan.intent.value, "intent": plan.intent.value,
"confidence": plan.confidence, "confidence": plan.confidence,

View file

@ -30,19 +30,20 @@ class MultiRootLocalFolderBackend:
where `<mount>` is derived from each selected root folder name. where `<mount>` is derived from each selected root folder name.
""" """
def __init__(self, root_paths: tuple[str, ...]) -> None: def __init__(self, mounts: tuple[tuple[str, str], ...]) -> None:
if not root_paths: if not mounts:
msg = "At least one local root path is required" msg = "At least one local mount is required"
raise ValueError(msg) raise ValueError(msg)
self._mount_to_backend: dict[str, LocalFolderBackend] = {} self._mount_to_backend: dict[str, LocalFolderBackend] = {}
for raw_root in root_paths: 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()) 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_to_backend[mount] = LocalFolderBackend(normalized_root)
self._mount_order = tuple(self._mount_to_backend.keys()) self._mount_order = tuple(self._mount_to_backend.keys())

View file

@ -24,6 +24,7 @@ from sqlalchemy.orm import selectinload
from app.agents.new_chat.filesystem_selection import ( from app.agents.new_chat.filesystem_selection import (
ClientPlatform, ClientPlatform,
LocalFilesystemMount,
FilesystemMode, FilesystemMode,
FilesystemSelection, FilesystemSelection,
) )
@ -42,6 +43,7 @@ from app.db import (
) )
from app.schemas.new_chat import ( from app.schemas.new_chat import (
AgentToolInfo, AgentToolInfo,
LocalFilesystemMountPayload,
NewChatMessageRead, NewChatMessageRead,
NewChatRequest, NewChatRequest,
NewChatThreadCreate, NewChatThreadCreate,
@ -73,7 +75,7 @@ def _resolve_filesystem_selection(
*, *,
mode: str, mode: str,
client_platform: str, client_platform: str,
local_roots: list[str] | None, local_mounts: list[LocalFilesystemMountPayload] | None,
) -> FilesystemSelection: ) -> FilesystemSelection:
"""Validate and normalize filesystem mode settings from request payload.""" """Validate and normalize filesystem mode settings from request payload."""
try: try:
@ -96,29 +98,37 @@ def _resolve_filesystem_selection(
status_code=400, status_code=400,
detail="desktop_local_folder mode is only available on desktop runtime.", detail="desktop_local_folder mode is only available on desktop runtime.",
) )
normalized_roots: list[str] = [] normalized_mounts: list[tuple[str, str]] = []
for root in local_roots or []: seen_mounts: set[str] = set()
trimmed = root.strip() for mount in local_mounts or []:
if trimmed and trimmed not in normalized_roots: mount_id = mount.mount_id.strip()
normalized_roots.append(trimmed) root_path = mount.root_path.strip()
if not normalized_roots: if not mount_id or not root_path:
continue
if mount_id in seen_mounts:
continue
seen_mounts.add(mount_id)
normalized_mounts.append((mount_id, root_path))
if not normalized_mounts:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=( detail=(
"local_filesystem_roots must include at least one root for " "local_filesystem_mounts must include at least one mount for "
"desktop_local_folder mode." "desktop_local_folder mode."
), ),
) )
return FilesystemSelection( return FilesystemSelection(
mode=resolved_mode, mode=resolved_mode,
client_platform=resolved_platform, client_platform=resolved_platform,
local_root_paths=tuple(normalized_roots), local_mounts=tuple(
LocalFilesystemMount(mount_id=mount_id, root_path=root_path)
for mount_id, root_path in normalized_mounts
),
) )
return FilesystemSelection( return FilesystemSelection(
mode=FilesystemMode.CLOUD, mode=FilesystemMode.CLOUD,
client_platform=resolved_platform, client_platform=resolved_platform,
local_root_paths=(),
) )
@ -1196,7 +1206,7 @@ async def handle_new_chat(
filesystem_selection = _resolve_filesystem_selection( filesystem_selection = _resolve_filesystem_selection(
mode=request.filesystem_mode, mode=request.filesystem_mode,
client_platform=request.client_platform, client_platform=request.client_platform,
local_roots=request.local_filesystem_roots, local_mounts=request.local_filesystem_mounts,
) )
# Get search space to check LLM config preferences # Get search space to check LLM config preferences
@ -1318,7 +1328,7 @@ async def regenerate_response(
filesystem_selection = _resolve_filesystem_selection( filesystem_selection = _resolve_filesystem_selection(
mode=request.filesystem_mode, mode=request.filesystem_mode,
client_platform=request.client_platform, client_platform=request.client_platform,
local_roots=request.local_filesystem_roots, local_mounts=request.local_filesystem_mounts,
) )
# Get the checkpointer and state history # Get the checkpointer and state history
@ -1577,7 +1587,7 @@ async def resume_chat(
filesystem_selection = _resolve_filesystem_selection( filesystem_selection = _resolve_filesystem_selection(
mode=request.filesystem_mode, mode=request.filesystem_mode,
client_platform=request.client_platform, client_platform=request.client_platform,
local_roots=request.local_filesystem_roots, local_mounts=request.local_filesystem_mounts,
) )
search_space_result = await session.execute( search_space_result = await session.execute(

View file

@ -168,6 +168,11 @@ class ChatMessage(BaseModel):
content: str content: str
class LocalFilesystemMountPayload(BaseModel):
mount_id: str
root_path: str
class NewChatRequest(BaseModel): class NewChatRequest(BaseModel):
"""Request schema for the deep agent chat endpoint.""" """Request schema for the deep agent chat endpoint."""
@ -186,7 +191,7 @@ class NewChatRequest(BaseModel):
) )
filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud" filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud"
client_platform: Literal["web", "desktop"] = "web" client_platform: Literal["web", "desktop"] = "web"
local_filesystem_roots: list[str] | None = None local_filesystem_mounts: list[LocalFilesystemMountPayload] | None = None
class RegenerateRequest(BaseModel): class RegenerateRequest(BaseModel):
@ -209,7 +214,7 @@ class RegenerateRequest(BaseModel):
disabled_tools: list[str] | None = None disabled_tools: list[str] | None = None
filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud" filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud"
client_platform: Literal["web", "desktop"] = "web" client_platform: Literal["web", "desktop"] = "web"
local_filesystem_roots: list[str] | None = None local_filesystem_mounts: list[LocalFilesystemMountPayload] | None = None
# ============================================================================= # =============================================================================
@ -235,7 +240,7 @@ class ResumeRequest(BaseModel):
decisions: list[ResumeDecision] decisions: list[ResumeDecision]
filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud" filesystem_mode: Literal["cloud", "desktop_local_folder"] = "cloud"
client_platform: Literal["web", "desktop"] = "web" client_platform: Literal["web", "desktop"] = "web"
local_filesystem_roots: list[str] | None = None local_filesystem_mounts: list[LocalFilesystemMountPayload] | None = None
# ============================================================================= # =============================================================================

View file

@ -190,6 +190,23 @@ def _tool_output_has_error(tool_output: Any) -> bool:
return False return False
def _extract_resolved_file_path(*, tool_name: str, tool_output: Any) -> str | None:
if isinstance(tool_output, dict):
path_value = tool_output.get("path")
if isinstance(path_value, str) and path_value.strip():
return path_value.strip()
text = _tool_output_to_text(tool_output)
if tool_name == "write_file":
match = re.search(r"Updated file\s+(.+)$", text.strip())
if match:
return match.group(1).strip()
if tool_name == "edit_file":
match = re.search(r"in '([^']+)'", text)
if match:
return match.group(1).strip()
return None
def _contract_enforcement_active(result: StreamResult) -> bool: def _contract_enforcement_active(result: StreamResult) -> bool:
# Keep policy deterministic with no env-driven progression modes: # Keep policy deterministic with no env-driven progression modes:
# enforce the file-operation contract only in desktop local-folder mode. # enforce the file-operation contract only in desktop local-folder mode.
@ -1016,6 +1033,30 @@ async def _stream_agent_events(
f"Scrape failed: {error_msg}", f"Scrape failed: {error_msg}",
"error", "error",
) )
elif tool_name in ("write_file", "edit_file"):
resolved_path = _extract_resolved_file_path(
tool_name=tool_name,
tool_output=tool_output,
)
result_text = _tool_output_to_text(tool_output)
if _tool_output_has_error(tool_output):
yield streaming_service.format_tool_output_available(
tool_call_id,
{
"status": "error",
"error": result_text,
"path": resolved_path,
},
)
else:
yield streaming_service.format_tool_output_available(
tool_call_id,
{
"status": "completed",
"path": resolved_path,
"result": result_text,
},
)
elif tool_name == "generate_report": elif tool_name == "generate_report":
# Stream the full report result so frontend can render the ReportCard # Stream the full report result so frontend can render the ReportCard
yield streaming_service.format_tool_output_available( yield streaming_service.format_tool_output_available(

View file

@ -114,3 +114,60 @@ async def test_file_write_txt_suggestion_is_normalized_to_markdown():
assert contract["intent"] == FileOperationIntent.FILE_WRITE.value assert contract["intent"] == FileOperationIntent.FILE_WRITE.value
assert contract["suggested_path"] == "/random.md" assert contract["suggested_path"] == "/random.md"
@pytest.mark.asyncio
async def test_file_write_with_suggested_directory_preserves_folder():
llm = _FakeLLM(
'{"intent":"file_write","confidence":0.86,"suggested_filename":"random.md","suggested_directory":"pc backups","suggested_path":null}'
)
middleware = FileIntentMiddleware(llm=llm)
state = {
"messages": [HumanMessage(content="create a random file in pc backups folder")],
"turn_id": "turn:4",
}
result = await middleware.abefore_agent(state, runtime=None) # type: ignore[arg-type]
assert result is not None
contract = result["file_operation_contract"]
assert contract["intent"] == FileOperationIntent.FILE_WRITE.value
assert contract["suggested_path"] == "/pc_backups/random.md"
@pytest.mark.asyncio
async def test_file_write_with_suggested_path_takes_precedence():
llm = _FakeLLM(
'{"intent":"file_write","confidence":0.9,"suggested_filename":"ignored.md","suggested_directory":"docs","suggested_path":"/reports/q2/summary.md"}'
)
middleware = FileIntentMiddleware(llm=llm)
state = {
"messages": [HumanMessage(content="create report")],
"turn_id": "turn:5",
}
result = await middleware.abefore_agent(state, runtime=None) # type: ignore[arg-type]
assert result is not None
contract = result["file_operation_contract"]
assert contract["intent"] == FileOperationIntent.FILE_WRITE.value
assert contract["suggested_path"] == "/reports/q2/summary.md"
@pytest.mark.asyncio
async def test_file_write_infers_directory_from_user_text_when_missing():
llm = _FakeLLM(
'{"intent":"file_write","confidence":0.83,"suggested_filename":"random.md","suggested_directory":null,"suggested_path":null}'
)
middleware = FileIntentMiddleware(llm=llm)
state = {
"messages": [HumanMessage(content="create a random file in pc backups folder")],
"turn_id": "turn:6",
}
result = await middleware.abefore_agent(state, runtime=None) # type: ignore[arg-type]
assert result is not None
contract = result["file_operation_contract"]
assert contract["intent"] == FileOperationIntent.FILE_WRITE.value
assert contract["suggested_path"] == "/pc_backups/random.md"

View file

@ -7,6 +7,7 @@ from app.agents.new_chat.filesystem_selection import (
ClientPlatform, ClientPlatform,
FilesystemMode, FilesystemMode,
FilesystemSelection, FilesystemSelection,
LocalFilesystemMount,
) )
from app.agents.new_chat.middleware.multi_root_local_folder_backend import ( from app.agents.new_chat.middleware.multi_root_local_folder_backend import (
MultiRootLocalFolderBackend, MultiRootLocalFolderBackend,
@ -23,7 +24,7 @@ def test_backend_resolver_returns_multi_root_backend_for_single_root(tmp_path: P
selection = FilesystemSelection( selection = FilesystemSelection(
mode=FilesystemMode.DESKTOP_LOCAL_FOLDER, mode=FilesystemMode.DESKTOP_LOCAL_FOLDER,
client_platform=ClientPlatform.DESKTOP, client_platform=ClientPlatform.DESKTOP,
local_root_paths=(str(tmp_path),), local_mounts=(LocalFilesystemMount(mount_id="tmp", root_path=str(tmp_path)),),
) )
resolver = build_backend_resolver(selection) resolver = build_backend_resolver(selection)
@ -47,7 +48,10 @@ def test_backend_resolver_returns_multi_root_backend_for_multiple_roots(tmp_path
selection = FilesystemSelection( selection = FilesystemSelection(
mode=FilesystemMode.DESKTOP_LOCAL_FOLDER, mode=FilesystemMode.DESKTOP_LOCAL_FOLDER,
client_platform=ClientPlatform.DESKTOP, client_platform=ClientPlatform.DESKTOP,
local_root_paths=(str(root_one), str(root_two)), local_mounts=(
LocalFilesystemMount(mount_id="resume", root_path=str(root_one)),
LocalFilesystemMount(mount_id="notes", root_path=str(root_two)),
),
) )
resolver = build_backend_resolver(selection) resolver = build_backend_resolver(selection)

View file

@ -1,5 +1,11 @@
from pathlib import Path
import pytest import pytest
from app.agents.new_chat.middleware.multi_root_local_folder_backend import (
MultiRootLocalFolderBackend,
)
from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.agents.new_chat.middleware.filesystem import SurfSenseFilesystemMiddleware from app.agents.new_chat.middleware.filesystem import SurfSenseFilesystemMiddleware
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
@ -43,9 +49,9 @@ def test_verify_written_content_prefers_raw_sync() -> None:
def test_contract_suggested_path_falls_back_to_notes_md() -> None: def test_contract_suggested_path_falls_back_to_notes_md() -> None:
suggested = SurfSenseFilesystemMiddleware._get_contract_suggested_path( middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware)
_RuntimeNoSuggestedPath() middleware._filesystem_mode = FilesystemMode.CLOUD
) suggested = middleware._get_contract_suggested_path(_RuntimeNoSuggestedPath()) # type: ignore[arg-type]
assert suggested == "/notes.md" assert suggested == "/notes.md"
@ -62,3 +68,32 @@ async def test_verify_written_content_prefers_raw_async() -> None:
) )
assert verify_error is None assert verify_error is None
def test_normalize_local_mount_path_prefixes_default_mount(tmp_path: Path) -> None:
root = tmp_path / "PC Backups"
root.mkdir()
backend = MultiRootLocalFolderBackend((("pc_backups", str(root)),))
runtime = _RuntimeNoSuggestedPath()
middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware)
middleware._get_backend = lambda _runtime: backend # type: ignore[method-assign]
resolved = middleware._normalize_local_mount_path("/random-note.md", runtime) # type: ignore[arg-type]
assert resolved == "/pc_backups/random-note.md"
def test_normalize_local_mount_path_keeps_explicit_mount(tmp_path: Path) -> None:
root = tmp_path / "PC Backups"
root.mkdir()
backend = MultiRootLocalFolderBackend((("pc_backups", str(root)),))
runtime = _RuntimeNoSuggestedPath()
middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware)
middleware._get_backend = lambda _runtime: backend # type: ignore[method-assign]
resolved = middleware._normalize_local_mount_path( # type: ignore[arg-type]
"/pc_backups/notes/random-note.md",
runtime,
)
assert resolved == "/pc_backups/notes/random-note.md"

View file

@ -0,0 +1,28 @@
from pathlib import Path
import pytest
from app.agents.new_chat.middleware.multi_root_local_folder_backend import (
MultiRootLocalFolderBackend,
)
pytestmark = pytest.mark.unit
def test_mount_ids_preserve_client_mapping_order(tmp_path: Path) -> None:
root_one = tmp_path / "PC Backups"
root_two = tmp_path / "pc_backups"
root_three = tmp_path / "notes@2026"
root_one.mkdir()
root_two.mkdir()
root_three.mkdir()
backend = MultiRootLocalFolderBackend(
(
("pc_backups", str(root_one)),
("pc_backups_2", str(root_two)),
("notes_2026", str(root_three)),
)
)
assert backend.list_mounts() == ("pc_backups", "pc_backups_2", "notes_2026")

View file

@ -127,12 +127,22 @@ export type LocalRootMount = {
rootPath: string; rootPath: string;
}; };
function sanitizeMountName(rawMount: string): string {
const normalized = rawMount
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^[_-]+|[_-]+$/g, "");
return normalized || "root";
}
function buildRootMounts(rootPaths: string[]): LocalRootMount[] { function buildRootMounts(rootPaths: string[]): LocalRootMount[] {
const mounts: LocalRootMount[] = []; const mounts: LocalRootMount[] = [];
const usedMounts = new Set<string>(); const usedMounts = new Set<string>();
for (const rawRootPath of rootPaths) { for (const rawRootPath of rootPaths) {
const normalizedRoot = resolve(rawRootPath); const normalizedRoot = resolve(rawRootPath);
const baseMount = normalizedRoot.split(/[\\/]/).at(-1) || "root"; const baseMount = sanitizeMountName(normalizedRoot.split(/[\\/]/).at(-1) || "root");
let mount = baseMount; let mount = baseMount;
let suffix = 2; let suffix = 2;
while (usedMounts.has(mount)) { while (usedMounts.has(mount)) {
@ -150,7 +160,10 @@ export async function getAgentFilesystemMounts(): Promise<LocalRootMount[]> {
return buildRootMounts(rootPaths); return buildRootMounts(rootPaths);
} }
function parseMountedVirtualPath(virtualPath: string): { function parseMountedVirtualPath(
virtualPath: string,
mounts: LocalRootMount[]
): {
mount: string; mount: string;
subPath: string; subPath: string;
} { } {
@ -161,8 +174,15 @@ function parseMountedVirtualPath(virtualPath: string): {
if (!trimmed) { if (!trimmed) {
throw new Error("Path must include a mounted root segment"); throw new Error("Path must include a mounted root segment");
} }
const [mount, ...rest] = trimmed.split("/"); const [mount, ...rest] = trimmed.split("/");
const remainder = rest.join("/"); const remainder = rest.join("/");
const directMount = mounts.find((entry) => entry.mount === mount);
if (!directMount) {
throw new Error(
`Unknown mounted root '${mount}'. Available roots: ${mounts.map((entry) => `/${entry.mount}`).join(", ")}`
);
}
if (!remainder) { if (!remainder) {
throw new Error("Path must include a file path under the mounted root"); throw new Error("Path must include a file path under the mounted root");
} }
@ -191,7 +211,7 @@ export async function readAgentLocalFileText(
): Promise<{ path: string; content: string }> { ): Promise<{ path: string; content: string }> {
const rootPaths = await resolveCurrentRootPaths(); const rootPaths = await resolveCurrentRootPaths();
const mounts = buildRootMounts(rootPaths); const mounts = buildRootMounts(rootPaths);
const { mount, subPath } = parseMountedVirtualPath(virtualPath); const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
const rootMount = findMountByName(mounts, mount); const rootMount = findMountByName(mounts, mount);
if (!rootMount) { if (!rootMount) {
throw new Error( throw new Error(
@ -212,7 +232,7 @@ export async function writeAgentLocalFileText(
): Promise<{ path: string }> { ): Promise<{ path: string }> {
const rootPaths = await resolveCurrentRootPaths(); const rootPaths = await resolveCurrentRootPaths();
const mounts = buildRootMounts(rootPaths); const mounts = buildRootMounts(rootPaths);
const { mount, subPath } = parseMountedVirtualPath(virtualPath); const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
const rootMount = findMountByName(mounts, mount); const rootMount = findMountByName(mounts, mount);
if (!rootMount) { if (!rootMount) {
throw new Error( throw new Error(

View file

@ -159,7 +159,7 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
/** /**
* Tools that should render custom UI in the chat. * Tools that should render custom UI in the chat.
*/ */
const TOOLS_WITH_UI = new Set([ const BASE_TOOLS_WITH_UI = new Set([
"web_search", "web_search",
"generate_podcast", "generate_podcast",
"generate_report", "generate_report",
@ -211,6 +211,7 @@ export default function NewChatPage() {
assistantMsgId: string; assistantMsgId: string;
interruptData: Record<string, unknown>; interruptData: Record<string, unknown>;
} | null>(null); } | null>(null);
const toolsWithUI = useMemo(() => new Set([...BASE_TOOLS_WITH_UI]), []);
// Get disabled tools from the tool toggle UI // Get disabled tools from the tool toggle UI
const disabledTools = useAtomValue(disabledToolsAtom); const disabledTools = useAtomValue(disabledToolsAtom);
@ -660,7 +661,8 @@ export default function NewChatPage() {
const selection = await getAgentFilesystemSelection(); const selection = await getAgentFilesystemSelection();
if ( if (
selection.filesystem_mode === "desktop_local_folder" && selection.filesystem_mode === "desktop_local_folder" &&
(!selection.local_filesystem_roots || selection.local_filesystem_roots.length === 0) (!selection.local_filesystem_mounts ||
selection.local_filesystem_mounts.length === 0)
) { ) {
toast.error("Select a local folder before using Local Folder mode."); toast.error("Select a local folder before using Local Folder mode.");
return; return;
@ -702,7 +704,7 @@ export default function NewChatPage() {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
filesystem_mode: selection.filesystem_mode, filesystem_mode: selection.filesystem_mode,
client_platform: selection.client_platform, client_platform: selection.client_platform,
local_filesystem_roots: selection.local_filesystem_roots, local_filesystem_mounts: selection.local_filesystem_mounts,
messages: messageHistory, messages: messageHistory,
mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined, mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined,
mentioned_surfsense_doc_ids: hasSurfsenseDocIds mentioned_surfsense_doc_ids: hasSurfsenseDocIds
@ -721,7 +723,7 @@ export default function NewChatPage() {
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantMsgId m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } ? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) }
: m : m
) )
); );
@ -736,7 +738,7 @@ export default function NewChatPage() {
break; break;
case "tool-input-start": case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); addToolCall(contentPartsState, toolsWithUI, parsed.toolCallId, parsed.toolName, {});
batcher.flush(); batcher.flush();
break; break;
@ -746,7 +748,7 @@ export default function NewChatPage() {
} else { } else {
addToolCall( addToolCall(
contentPartsState, contentPartsState,
TOOLS_WITH_UI, toolsWithUI,
parsed.toolCallId, parsed.toolCallId,
parsed.toolName, parsed.toolName,
parsed.input || {} parsed.input || {}
@ -842,7 +844,7 @@ export default function NewChatPage() {
const tcId = `interrupt-${action.name}`; const tcId = `interrupt-${action.name}`;
addToolCall( addToolCall(
contentPartsState, contentPartsState,
TOOLS_WITH_UI, toolsWithUI,
tcId, tcId,
action.name, action.name,
action.args, action.args,
@ -856,7 +858,7 @@ export default function NewChatPage() {
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantMsgId m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } ? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) }
: m : m
) )
); );
@ -883,7 +885,7 @@ export default function NewChatPage() {
batcher.flush(); batcher.flush();
// Skip persistence for interrupted messages -- handleResume will persist the final version // Skip persistence for interrupted messages -- handleResume will persist the final version
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); const finalContent = buildContentForPersistence(contentPartsState, toolsWithUI);
if (contentParts.length > 0 && !wasInterrupted) { if (contentParts.length > 0 && !wasInterrupted) {
try { try {
const savedMessage = await appendMessage(currentThreadId, { const savedMessage = await appendMessage(currentThreadId, {
@ -919,10 +921,10 @@ export default function NewChatPage() {
const hasContent = contentParts.some( const hasContent = contentParts.some(
(part) => (part) =>
(part.type === "text" && part.text.length > 0) || (part.type === "text" && part.text.length > 0) ||
(part.type === "tool-call" && TOOLS_WITH_UI.has(part.toolName)) (part.type === "tool-call" && toolsWithUI.has(part.toolName))
); );
if (hasContent && currentThreadId) { if (hasContent && currentThreadId) {
const partialContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); const partialContent = buildContentForPersistence(contentPartsState, toolsWithUI);
try { try {
const savedMessage = await appendMessage(currentThreadId, { const savedMessage = await appendMessage(currentThreadId, {
role: "assistant", role: "assistant",
@ -1098,7 +1100,7 @@ export default function NewChatPage() {
decisions, decisions,
filesystem_mode: selection.filesystem_mode, filesystem_mode: selection.filesystem_mode,
client_platform: selection.client_platform, client_platform: selection.client_platform,
local_filesystem_roots: selection.local_filesystem_roots, local_filesystem_mounts: selection.local_filesystem_mounts,
}), }),
signal: controller.signal, signal: controller.signal,
}); });
@ -1111,7 +1113,7 @@ export default function NewChatPage() {
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantMsgId m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } ? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) }
: m : m
) )
); );
@ -1126,7 +1128,7 @@ export default function NewChatPage() {
break; break;
case "tool-input-start": case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); addToolCall(contentPartsState, toolsWithUI, parsed.toolCallId, parsed.toolName, {});
batcher.flush(); batcher.flush();
break; break;
@ -1138,7 +1140,7 @@ export default function NewChatPage() {
} else { } else {
addToolCall( addToolCall(
contentPartsState, contentPartsState,
TOOLS_WITH_UI, toolsWithUI,
parsed.toolCallId, parsed.toolCallId,
parsed.toolName, parsed.toolName,
parsed.input || {} parsed.input || {}
@ -1189,7 +1191,7 @@ export default function NewChatPage() {
const tcId = `interrupt-${action.name}`; const tcId = `interrupt-${action.name}`;
addToolCall( addToolCall(
contentPartsState, contentPartsState,
TOOLS_WITH_UI, toolsWithUI,
tcId, tcId,
action.name, action.name,
action.args, action.args,
@ -1206,7 +1208,7 @@ export default function NewChatPage() {
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantMsgId m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } ? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) }
: m : m
) )
); );
@ -1230,7 +1232,7 @@ export default function NewChatPage() {
batcher.flush(); batcher.flush();
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); const finalContent = buildContentForPersistence(contentPartsState, toolsWithUI);
if (contentParts.length > 0) { if (contentParts.length > 0) {
try { try {
const savedMessage = await appendMessage(resumeThreadId, { const savedMessage = await appendMessage(resumeThreadId, {
@ -1435,7 +1437,7 @@ export default function NewChatPage() {
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined, disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
filesystem_mode: selection.filesystem_mode, filesystem_mode: selection.filesystem_mode,
client_platform: selection.client_platform, client_platform: selection.client_platform,
local_filesystem_roots: selection.local_filesystem_roots, local_filesystem_mounts: selection.local_filesystem_mounts,
}), }),
signal: controller.signal, signal: controller.signal,
}); });
@ -1448,7 +1450,7 @@ export default function NewChatPage() {
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantMsgId m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } ? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) }
: m : m
) )
); );
@ -1463,7 +1465,7 @@ export default function NewChatPage() {
break; break;
case "tool-input-start": case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); addToolCall(contentPartsState, toolsWithUI, parsed.toolCallId, parsed.toolName, {});
batcher.flush(); batcher.flush();
break; break;
@ -1473,7 +1475,7 @@ export default function NewChatPage() {
} else { } else {
addToolCall( addToolCall(
contentPartsState, contentPartsState,
TOOLS_WITH_UI, toolsWithUI,
parsed.toolCallId, parsed.toolCallId,
parsed.toolName, parsed.toolName,
parsed.input || {} parsed.input || {}
@ -1522,7 +1524,7 @@ export default function NewChatPage() {
batcher.flush(); batcher.flush();
// Persist messages after streaming completes // Persist messages after streaming completes
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); const finalContent = buildContentForPersistence(contentPartsState, toolsWithUI);
if (contentParts.length > 0) { if (contentParts.length > 0) {
try { try {
// Persist user message (for both edit and reload modes, since backend deleted it) // Persist user message (for both edit and reload modes, since backend deleted it)

View file

@ -226,10 +226,16 @@ function extractDomain(url: string): string {
} }
} }
const LOCAL_FILE_PATH_REGEX = /^\/(?:[^/\s`]+\/)*[^/\s`]+\.[^/\s`]+$/; // Canonical local-file virtual paths are mount-prefixed: /<mount>/<relative/path>
const LOCAL_FILE_PATH_REGEX = /^\/[a-z0-9_-]+\/[^\s`]+(?:\/[^\s`]+)*$/;
function isVirtualFilePathToken(value: string): boolean { function isVirtualFilePathToken(value: string): boolean {
return LOCAL_FILE_PATH_REGEX.test(value); if (!LOCAL_FILE_PATH_REGEX.test(value) || value.startsWith("//")) {
return false;
}
const normalized = value.replace(/\/+$/, "");
const segments = normalized.split("/").filter(Boolean);
return segments.length >= 2;
} }
function MarkdownImage({ src, alt }: { src?: string; alt?: string }) { function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {

View file

@ -31,6 +31,12 @@ export function SourceCodeEditor({
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const onSaveRef = useRef(onSave); const onSaveRef = useRef(onSave);
const monacoRef = useRef<any>(null); const monacoRef = useRef<any>(null);
const normalizedModelPath = (() => {
const raw = (path || "local-file.txt").trim();
const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`;
// Monaco model paths should be stable and POSIX-like across platforms.
return withLeadingSlash.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
})();
useEffect(() => { useEffect(() => {
onSaveRef.current = onSave; onSaveRef.current = onSave;
@ -82,7 +88,7 @@ export function SourceCodeEditor({
return ( return (
<div className="h-full w-full overflow-hidden bg-sidebar [&_.monaco-editor]:!bg-sidebar [&_.monaco-editor_.margin]:!bg-sidebar [&_.monaco-editor_.monaco-editor-background]:!bg-sidebar [&_.monaco-editor-background]:!bg-sidebar [&_.monaco-scrollable-element_.scrollbar_.slider]:rounded-full [&_.monaco-scrollable-element_.scrollbar_.slider]:bg-foreground/25 [&_.monaco-scrollable-element_.scrollbar_.slider:hover]:bg-foreground/40"> <div className="h-full w-full overflow-hidden bg-sidebar [&_.monaco-editor]:!bg-sidebar [&_.monaco-editor_.margin]:!bg-sidebar [&_.monaco-editor_.monaco-editor-background]:!bg-sidebar [&_.monaco-editor-background]:!bg-sidebar [&_.monaco-scrollable-element_.scrollbar_.slider]:rounded-full [&_.monaco-scrollable-element_.scrollbar_.slider]:bg-foreground/25 [&_.monaco-scrollable-element_.scrollbar_.slider:hover]:bg-foreground/40">
<MonacoEditor <MonacoEditor
path={path} path={normalizedModelPath}
language={language} language={language}
value={value} value={value}
theme={resolvedTheme === "dark" ? "surfsense-dark" : "surfsense-light"} theme={resolvedTheme === "dark" ? "surfsense-dark" : "surfsense-light"}

View file

@ -1,10 +1,15 @@
export type AgentFilesystemMode = "cloud" | "desktop_local_folder"; export type AgentFilesystemMode = "cloud" | "desktop_local_folder";
export type ClientPlatform = "web" | "desktop"; export type ClientPlatform = "web" | "desktop";
export interface AgentFilesystemMountSelection {
mount_id: string;
root_path: string;
}
export interface AgentFilesystemSelection { export interface AgentFilesystemSelection {
filesystem_mode: AgentFilesystemMode; filesystem_mode: AgentFilesystemMode;
client_platform: ClientPlatform; client_platform: ClientPlatform;
local_filesystem_roots?: string[]; local_filesystem_mounts?: AgentFilesystemMountSelection[];
} }
const DEFAULT_SELECTION: AgentFilesystemSelection = { const DEFAULT_SELECTION: AgentFilesystemSelection = {
@ -24,12 +29,23 @@ export async function getAgentFilesystemSelection(): Promise<AgentFilesystemSele
} }
try { try {
const settings = await window.electronAPI.getAgentFilesystemSettings(); const settings = await window.electronAPI.getAgentFilesystemSettings();
const firstLocalRootPath = settings.localRootPaths[0]; if (settings.mode === "desktop_local_folder") {
if (settings.mode === "desktop_local_folder" && firstLocalRootPath) { const mounts = await window.electronAPI.getAgentFilesystemMounts?.();
const localFilesystemMounts =
mounts?.map((entry) => ({
mount_id: entry.mount,
root_path: entry.rootPath,
})) ?? [];
if (localFilesystemMounts.length === 0) {
return {
filesystem_mode: "cloud",
client_platform: "desktop",
};
}
return { return {
filesystem_mode: "desktop_local_folder", filesystem_mode: "desktop_local_folder",
client_platform: "desktop", client_platform: "desktop",
local_filesystem_roots: settings.localRootPaths, local_filesystem_mounts: localFilesystemMounts,
}; };
} }
return { return {