mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-19 18:45:15 +02:00
multi_agent_chat/filesystem: extract dedicated FS middleware package
This commit is contained in:
parent
df2afed18d
commit
3adfa37565
61 changed files with 2689 additions and 2 deletions
|
|
@ -0,0 +1,11 @@
|
||||||
|
"""SurfSense filesystem middleware (multi-agent flavour)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .index import build_filesystem_mw
|
||||||
|
from .middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SurfSenseFilesystemMiddleware",
|
||||||
|
"build_filesystem_mw",
|
||||||
|
]
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
"""SurfSense filesystem tools/middleware."""
|
"""Public composition factory for the filesystem middleware."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
from app.agents.new_chat.middleware import SurfSenseFilesystemMiddleware
|
|
||||||
|
from .middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
def build_filesystem_mw(
|
def build_filesystem_mw(
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""SurfSense filesystem middleware: class + focused-responsibility helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .index import (
|
||||||
|
SurfSenseFilesystemMiddleware,
|
||||||
|
check_cloud_write_namespace,
|
||||||
|
current_cwd,
|
||||||
|
default_cwd,
|
||||||
|
get_contract_suggested_path,
|
||||||
|
is_cloud,
|
||||||
|
normalize_local_mount_path,
|
||||||
|
resolve_list_target_path,
|
||||||
|
resolve_move_target_path,
|
||||||
|
resolve_relative,
|
||||||
|
resolve_write_target_path,
|
||||||
|
run_async_blocking,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SurfSenseFilesystemMiddleware",
|
||||||
|
"check_cloud_write_namespace",
|
||||||
|
"current_cwd",
|
||||||
|
"default_cwd",
|
||||||
|
"get_contract_suggested_path",
|
||||||
|
"is_cloud",
|
||||||
|
"normalize_local_mount_path",
|
||||||
|
"resolve_list_target_path",
|
||||||
|
"resolve_move_target_path",
|
||||||
|
"resolve_relative",
|
||||||
|
"resolve_write_target_path",
|
||||||
|
"run_async_blocking",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Sync/async dispatcher: drive an async tool body from a sync entry-point."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def run_async_blocking(coro: Any) -> Any:
|
||||||
|
"""Run ``coro`` to completion, blocking the current thread.
|
||||||
|
|
||||||
|
Returns an error string instead of raising if the current thread is
|
||||||
|
already inside a running event loop — keeps sync tool entry-points
|
||||||
|
safe to call from any context.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
if loop.is_running():
|
||||||
|
return "Error: sync filesystem operation not supported inside an active event loop."
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Public surface of the middleware package: class + helpers used by tool factories."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .async_dispatch import run_async_blocking
|
||||||
|
from .middleware import SurfSenseFilesystemMiddleware
|
||||||
|
from .mode import default_cwd, is_cloud
|
||||||
|
from .namespace_policy import check_cloud_write_namespace
|
||||||
|
from .path_resolution import (
|
||||||
|
current_cwd,
|
||||||
|
get_contract_suggested_path,
|
||||||
|
normalize_local_mount_path,
|
||||||
|
resolve_list_target_path,
|
||||||
|
resolve_move_target_path,
|
||||||
|
resolve_relative,
|
||||||
|
resolve_write_target_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SurfSenseFilesystemMiddleware",
|
||||||
|
"check_cloud_write_namespace",
|
||||||
|
"current_cwd",
|
||||||
|
"default_cwd",
|
||||||
|
"get_contract_suggested_path",
|
||||||
|
"is_cloud",
|
||||||
|
"normalize_local_mount_path",
|
||||||
|
"resolve_list_target_path",
|
||||||
|
"resolve_move_target_path",
|
||||||
|
"resolve_relative",
|
||||||
|
"resolve_write_target_path",
|
||||||
|
"run_async_blocking",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
"""``SurfSenseFilesystemMiddleware``: per-session state + tool registration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from deepagents import FilesystemMiddleware
|
||||||
|
from langchain_core.tools import BaseTool
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
from app.agents.new_chat.sandbox import is_sandbox_enabled
|
||||||
|
|
||||||
|
from ..system_prompt import build_system_prompt
|
||||||
|
from ..tools import (
|
||||||
|
create_cd_tool,
|
||||||
|
create_edit_file_tool,
|
||||||
|
create_execute_code_tool,
|
||||||
|
create_list_tree_tool,
|
||||||
|
create_ls_tool,
|
||||||
|
create_mkdir_tool,
|
||||||
|
create_move_file_tool,
|
||||||
|
create_pwd_tool,
|
||||||
|
create_read_file_tool,
|
||||||
|
create_rm_tool,
|
||||||
|
create_rmdir_tool,
|
||||||
|
create_write_file_tool,
|
||||||
|
)
|
||||||
|
from ..tools.glob.description import select_description as glob_description
|
||||||
|
from ..tools.grep.description import select_description as grep_description
|
||||||
|
|
||||||
|
|
||||||
|
class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
|
||||||
|
"""SurfSense-specific filesystem middleware (cloud + desktop)."""
|
||||||
|
|
||||||
|
state_schema = SurfSenseFilesystemState
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
backend: Any = None,
|
||||||
|
filesystem_mode: FilesystemMode = FilesystemMode.CLOUD,
|
||||||
|
search_space_id: int | None = None,
|
||||||
|
created_by_id: str | None = None,
|
||||||
|
thread_id: int | str | None = None,
|
||||||
|
tool_token_limit_before_evict: int | None = 20000,
|
||||||
|
) -> None:
|
||||||
|
self._filesystem_mode = filesystem_mode
|
||||||
|
self._search_space_id = search_space_id
|
||||||
|
self._created_by_id = created_by_id
|
||||||
|
self._thread_id = thread_id
|
||||||
|
self._sandbox_available = is_sandbox_enabled() and thread_id is not None
|
||||||
|
|
||||||
|
system_prompt = build_system_prompt(
|
||||||
|
filesystem_mode,
|
||||||
|
sandbox_available=self._sandbox_available,
|
||||||
|
)
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
backend=backend,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
tool_token_limit_before_evict=tool_token_limit_before_evict,
|
||||||
|
)
|
||||||
|
self.tools = [t for t in self.tools if t.name != "execute"]
|
||||||
|
self.tools.append(create_mkdir_tool(self))
|
||||||
|
self.tools.append(create_cd_tool(self))
|
||||||
|
self.tools.append(create_pwd_tool(self))
|
||||||
|
self.tools.append(create_move_file_tool(self))
|
||||||
|
self.tools.append(create_rm_tool(self))
|
||||||
|
self.tools.append(create_rmdir_tool(self))
|
||||||
|
self.tools.append(create_list_tree_tool(self))
|
||||||
|
if self._sandbox_available:
|
||||||
|
self.tools.append(create_execute_code_tool(self))
|
||||||
|
|
||||||
|
# ----------------------------------------- base-class tool overrides
|
||||||
|
|
||||||
|
def _create_ls_tool(self) -> BaseTool:
|
||||||
|
return create_ls_tool(self)
|
||||||
|
|
||||||
|
def _create_read_file_tool(self) -> BaseTool:
|
||||||
|
return create_read_file_tool(self)
|
||||||
|
|
||||||
|
def _create_write_file_tool(self) -> BaseTool:
|
||||||
|
return create_write_file_tool(self)
|
||||||
|
|
||||||
|
def _create_edit_file_tool(self) -> BaseTool:
|
||||||
|
return create_edit_file_tool(self)
|
||||||
|
|
||||||
|
def _create_glob_tool(self) -> BaseTool:
|
||||||
|
tool = super()._create_glob_tool()
|
||||||
|
tool.description = glob_description(self._filesystem_mode).rstrip()
|
||||||
|
return tool
|
||||||
|
|
||||||
|
def _create_grep_tool(self) -> BaseTool:
|
||||||
|
tool = super()._create_grep_tool()
|
||||||
|
tool.description = grep_description(self._filesystem_mode).rstrip()
|
||||||
|
return tool
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
"""Mode-derived facts: ``is_cloud`` and ``default_cwd``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
from app.agents.new_chat.path_resolver import DOCUMENTS_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def is_cloud(mode: FilesystemMode) -> bool:
|
||||||
|
return mode == FilesystemMode.CLOUD
|
||||||
|
|
||||||
|
|
||||||
|
def default_cwd(mode: FilesystemMode) -> str:
|
||||||
|
"""``/documents`` on cloud; ``/`` on desktop (mounts are children of ``/``)."""
|
||||||
|
return DOCUMENTS_ROOT if is_cloud(mode) else "/"
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Cloud-only write namespace policy.
|
||||||
|
|
||||||
|
A write is allowed iff it lands under ``/documents/`` OR its basename uses
|
||||||
|
the ``temp_`` scratch prefix. The anonymous uploaded document is read-only
|
||||||
|
even when its path is under ``/documents/``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
from app.agents.new_chat.path_resolver import DOCUMENTS_ROOT
|
||||||
|
|
||||||
|
from ..shared.paths import TEMP_PREFIX, basename
|
||||||
|
from .mode import is_cloud
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def check_cloud_write_namespace(
|
||||||
|
mw: "SurfSenseFilesystemMiddleware",
|
||||||
|
path: str,
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> str | None:
|
||||||
|
"""Return an error string if cloud writes to ``path`` are not allowed.
|
||||||
|
|
||||||
|
Order matters:
|
||||||
|
1. Reject writes to the anonymous read-only doc.
|
||||||
|
2. Allow ``/documents/*``.
|
||||||
|
3. Allow ``temp_*`` basename anywhere.
|
||||||
|
4. Reject everything else.
|
||||||
|
"""
|
||||||
|
if not is_cloud(mw._filesystem_mode):
|
||||||
|
return None
|
||||||
|
anon = runtime.state.get("kb_anon_doc") or {}
|
||||||
|
if isinstance(anon, dict):
|
||||||
|
anon_path = str(anon.get("path") or "")
|
||||||
|
if anon_path and anon_path == path:
|
||||||
|
return "Error: the anonymous uploaded document is read-only."
|
||||||
|
if path.startswith(DOCUMENTS_ROOT + "/") or path == DOCUMENTS_ROOT:
|
||||||
|
return None
|
||||||
|
if basename(path).startswith(TEMP_PREFIX):
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
"Error: cloud writes must target /documents/<...> or use a 'temp_' "
|
||||||
|
f"basename for scratch (got '{path}')."
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
"""Resolve user-supplied paths to absolute paths the backends accept."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import posixpath
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
from app.agents.new_chat.middleware.multi_root_local_folder_backend import (
|
||||||
|
MultiRootLocalFolderBackend,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..shared.paths import (
|
||||||
|
extract_mount_from_path,
|
||||||
|
local_parent_path,
|
||||||
|
normalize_absolute_path,
|
||||||
|
)
|
||||||
|
from .mode import default_cwd
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def current_cwd(
|
||||||
|
mw: "SurfSenseFilesystemMiddleware",
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> str:
|
||||||
|
cwd = runtime.state.get("cwd") if hasattr(runtime, "state") else None
|
||||||
|
if isinstance(cwd, str) and cwd.startswith("/"):
|
||||||
|
return cwd
|
||||||
|
return default_cwd(mw._filesystem_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def get_contract_suggested_path(
|
||||||
|
mw: "SurfSenseFilesystemMiddleware",
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> str:
|
||||||
|
"""Read the planner's suggested write path; otherwise default to ``notes.md``."""
|
||||||
|
contract = runtime.state.get("file_operation_contract") or {}
|
||||||
|
suggested = contract.get("suggested_path")
|
||||||
|
if isinstance(suggested, str) and suggested.strip():
|
||||||
|
return normalize_absolute_path(suggested)
|
||||||
|
return default_cwd(mw._filesystem_mode).rstrip("/") + "/notes.md"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_relative(
|
||||||
|
mw: "SurfSenseFilesystemMiddleware",
|
||||||
|
path: str,
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> str:
|
||||||
|
"""Resolve ``path`` against cwd (no-op if already absolute)."""
|
||||||
|
candidate = path.strip()
|
||||||
|
if not candidate:
|
||||||
|
return current_cwd(mw, runtime)
|
||||||
|
if candidate.startswith("/"):
|
||||||
|
return normalize_absolute_path(candidate)
|
||||||
|
cwd = current_cwd(mw, runtime)
|
||||||
|
joined = posixpath.normpath(posixpath.join(cwd, candidate))
|
||||||
|
return normalize_absolute_path(joined)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_write_target_path(
|
||||||
|
mw: "SurfSenseFilesystemMiddleware",
|
||||||
|
file_path: str,
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> str:
|
||||||
|
"""Empty → contract suggestion; desktop → mount-prefix; cloud → cwd-relative."""
|
||||||
|
candidate = file_path.strip()
|
||||||
|
if not candidate:
|
||||||
|
return get_contract_suggested_path(mw, runtime)
|
||||||
|
if mw._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER:
|
||||||
|
return normalize_local_mount_path(mw, candidate, runtime)
|
||||||
|
return resolve_relative(mw, candidate, runtime)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_move_target_path(
|
||||||
|
mw: "SurfSenseFilesystemMiddleware",
|
||||||
|
file_path: str,
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> str:
|
||||||
|
"""Empty → empty (caller validates); desktop → mount-prefix; cloud → cwd-relative."""
|
||||||
|
candidate = file_path.strip()
|
||||||
|
if not candidate:
|
||||||
|
return ""
|
||||||
|
if mw._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER:
|
||||||
|
return normalize_local_mount_path(mw, candidate, runtime)
|
||||||
|
return resolve_relative(mw, candidate, runtime)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_list_target_path(
|
||||||
|
mw: "SurfSenseFilesystemMiddleware",
|
||||||
|
path: str,
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> str:
|
||||||
|
"""Root stays root; desktop → mount-prefix; cloud → cwd-relative."""
|
||||||
|
candidate = path.strip() or current_cwd(mw, runtime)
|
||||||
|
if candidate == "/":
|
||||||
|
return "/"
|
||||||
|
if mw._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER:
|
||||||
|
return normalize_local_mount_path(mw, candidate, runtime)
|
||||||
|
return resolve_relative(mw, candidate, runtime)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_local_mount_path(
|
||||||
|
mw: "SurfSenseFilesystemMiddleware",
|
||||||
|
candidate: str,
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> str:
|
||||||
|
"""Desktop only: prepend a mount prefix when the path doesn't already have one.
|
||||||
|
|
||||||
|
Resolution order: explicit mount prefix → single available mount →
|
||||||
|
contract-suggested mount → mount where the path exists → mount where the
|
||||||
|
parent exists → backend default mount.
|
||||||
|
"""
|
||||||
|
normalized = normalize_absolute_path(candidate)
|
||||||
|
backend = mw._get_backend(runtime)
|
||||||
|
if not isinstance(backend, MultiRootLocalFolderBackend):
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
mounts = backend.list_mounts()
|
||||||
|
explicit_mount = extract_mount_from_path(normalized, mounts)
|
||||||
|
if explicit_mount:
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
if len(mounts) == 1:
|
||||||
|
return f"/{mounts[0]}{normalized}"
|
||||||
|
|
||||||
|
suggested_mount: str | None = None
|
||||||
|
contract = runtime.state.get("file_operation_contract") or {}
|
||||||
|
suggested_path = contract.get("suggested_path")
|
||||||
|
if isinstance(suggested_path, str) and suggested_path.strip():
|
||||||
|
normalized_suggested = normalize_absolute_path(suggested_path)
|
||||||
|
suggested_mount = extract_mount_from_path(normalized_suggested, mounts)
|
||||||
|
|
||||||
|
matching_mounts = [
|
||||||
|
mount
|
||||||
|
for mount in mounts
|
||||||
|
if _path_exists_under_mount(backend, mount, normalized)
|
||||||
|
]
|
||||||
|
if len(matching_mounts) == 1:
|
||||||
|
return f"/{matching_mounts[0]}{normalized}"
|
||||||
|
|
||||||
|
parent_path = local_parent_path(normalized)
|
||||||
|
if parent_path != "/":
|
||||||
|
parent_matching_mounts = [
|
||||||
|
mount
|
||||||
|
for mount in mounts
|
||||||
|
if _path_exists_under_mount(backend, mount, parent_path)
|
||||||
|
]
|
||||||
|
if len(parent_matching_mounts) == 1:
|
||||||
|
return f"/{parent_matching_mounts[0]}{normalized}"
|
||||||
|
|
||||||
|
if suggested_mount:
|
||||||
|
return f"/{suggested_mount}{normalized}"
|
||||||
|
|
||||||
|
return f"/{backend.default_mount()}{normalized}"
|
||||||
|
|
||||||
|
|
||||||
|
def _path_exists_under_mount(
|
||||||
|
backend: MultiRootLocalFolderBackend,
|
||||||
|
mount: str,
|
||||||
|
local_path: str,
|
||||||
|
) -> bool:
|
||||||
|
result = backend.list_tree(
|
||||||
|
f"/{mount}{local_path}",
|
||||||
|
max_depth=0,
|
||||||
|
page_size=1,
|
||||||
|
include_files=True,
|
||||||
|
include_dirs=True,
|
||||||
|
)
|
||||||
|
return not bool(result.get("error"))
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""Stateless utilities shared by the middleware and tool factories."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .paths import (
|
||||||
|
TEMP_PREFIX,
|
||||||
|
basename,
|
||||||
|
extract_mount_from_path,
|
||||||
|
is_ancestor_of,
|
||||||
|
local_parent_path,
|
||||||
|
normalize_absolute_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"TEMP_PREFIX",
|
||||||
|
"basename",
|
||||||
|
"extract_mount_from_path",
|
||||||
|
"is_ancestor_of",
|
||||||
|
"local_parent_path",
|
||||||
|
"normalize_absolute_path",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Stateless path utilities shared by the middleware class and tool factories."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
TEMP_PREFIX = "temp_"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_absolute_path(candidate: str) -> str:
|
||||||
|
"""Collapse slashes / backslashes and force an absolute path."""
|
||||||
|
normalized = re.sub(r"/+", "/", candidate.strip().replace("\\", "/"))
|
||||||
|
if not normalized:
|
||||||
|
return "/"
|
||||||
|
if normalized.startswith("/"):
|
||||||
|
return normalized
|
||||||
|
return f"/{normalized.lstrip('/')}"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_mount_from_path(path: str, mounts: tuple[str, ...]) -> str | None:
|
||||||
|
"""Return the leading mount segment if it's in ``mounts``, else None."""
|
||||||
|
rel = path.lstrip("/")
|
||||||
|
if not rel:
|
||||||
|
return None
|
||||||
|
mount, _, _ = rel.partition("/")
|
||||||
|
if mount in mounts:
|
||||||
|
return mount
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def local_parent_path(path: str) -> str:
|
||||||
|
"""Posix-style parent path (root = ``/``)."""
|
||||||
|
rel = path.lstrip("/")
|
||||||
|
if "/" not in rel:
|
||||||
|
return "/"
|
||||||
|
parent = rel.rsplit("/", 1)[0].strip("/")
|
||||||
|
if not parent:
|
||||||
|
return "/"
|
||||||
|
return f"/{parent}"
|
||||||
|
|
||||||
|
|
||||||
|
def basename(path: str) -> str:
|
||||||
|
return path.rsplit("/", 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def is_ancestor_of(candidate: str, target: str) -> bool:
|
||||||
|
"""True iff ``candidate`` is a strict-or-equal ancestor of ``target``."""
|
||||||
|
if candidate == "/":
|
||||||
|
return target != "/"
|
||||||
|
cand = candidate.rstrip("/")
|
||||||
|
return target == cand or target.startswith(cand + "/")
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Filesystem-middleware system prompt (cloud + desktop modes)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .index import build_system_prompt
|
||||||
|
|
||||||
|
__all__ = ["build_system_prompt"]
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""Cloud-mode filesystem system prompt body."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
BODY = """
|
||||||
|
## Filesystem Tools
|
||||||
|
|
||||||
|
All file paths must start with `/`. Relative paths resolve against the
|
||||||
|
current working directory (`cwd`, default `/documents`).
|
||||||
|
|
||||||
|
- ls(path, offset=0, limit=200): list files and directories at the given path.
|
||||||
|
- read_file(path, offset, limit): read a file (paginated) from the filesystem.
|
||||||
|
- write_file(path, content): create a new text file in the workspace.
|
||||||
|
- edit_file(path, old, new): exact string-replacement edit (lazy-loads KB
|
||||||
|
documents on first edit).
|
||||||
|
- glob(pattern, path): find files matching a glob pattern.
|
||||||
|
- grep(pattern, path, glob): substring search across files.
|
||||||
|
- mkdir(path): create a folder under `/documents/` (committed at end of turn).
|
||||||
|
- cd(path): change the current working directory.
|
||||||
|
- pwd(): print the current working directory.
|
||||||
|
- move_file(source, dest): move/rename a file under `/documents/`.
|
||||||
|
- rm(path): delete a single file under `/documents/` (no `-r`).
|
||||||
|
- rmdir(path): delete an empty directory under `/documents/`.
|
||||||
|
- list_tree(path, max_depth, page_size): recursively list files/folders.
|
||||||
|
|
||||||
|
## Persistence Rules
|
||||||
|
|
||||||
|
- Files written under `/documents/<...>` are **persisted** at end of turn as
|
||||||
|
Documents in the user's knowledge base.
|
||||||
|
- Files whose **basename** starts with `temp_` (e.g. `temp_plan.md` or
|
||||||
|
`/documents/temp_scratch.md`) are **discarded** at end of turn — use this
|
||||||
|
prefix for any scratch/working content you do NOT want saved.
|
||||||
|
- All other paths (outside `/documents/` and not `temp_*`) are rejected.
|
||||||
|
- mkdir/move_file/rm/rmdir are staged this turn and committed at end of
|
||||||
|
turn alongside any new/edited documents. Snapshot/revert is enabled
|
||||||
|
for every destructive operation when action logging is on.
|
||||||
|
|
||||||
|
## Reading Documents Efficiently
|
||||||
|
|
||||||
|
Documents are formatted as XML. Each document contains:
|
||||||
|
- `<document_metadata>` — title, type, URL, etc.
|
||||||
|
- `<chunk_index>` — a table of every chunk with its **line range** and a
|
||||||
|
`matched="true"` flag for chunks that matched the search query.
|
||||||
|
- `<document_content>` — the actual chunks in original document order.
|
||||||
|
|
||||||
|
**Workflow**: when reading a large document, read the first ~20 lines to see
|
||||||
|
the `<chunk_index>`, identify chunks marked `matched="true"`, then use
|
||||||
|
`read_file(path, offset=<start_line>, limit=<lines>)` to jump directly to
|
||||||
|
those sections instead of reading the entire file sequentially.
|
||||||
|
|
||||||
|
Use `<chunk id='...'>` values as citation IDs in your answers.
|
||||||
|
|
||||||
|
## Priority List
|
||||||
|
|
||||||
|
You receive a `<priority_documents>` system message each turn listing the
|
||||||
|
top-K paths most relevant to the user's query (by hybrid search). Read those
|
||||||
|
first — matched sections are flagged inside each document's `<chunk_index>`.
|
||||||
|
|
||||||
|
## Workspace Tree
|
||||||
|
|
||||||
|
You receive a `<workspace_tree>` system message each turn with the current
|
||||||
|
folder/document layout. The tree may be truncated past a hard cap; in that
|
||||||
|
case, drill into specific folders with `ls(...)` or `list_tree(...)`.
|
||||||
|
|
||||||
|
## grep Line Numbers
|
||||||
|
|
||||||
|
`grep` searches across both your in-memory edits and the indexed chunks in
|
||||||
|
Postgres. State-cached files return real line numbers; database hits return
|
||||||
|
`line=0` because their position depends on per-document XML layout — call
|
||||||
|
`read_file(path)` to find the exact line.
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Mode-agnostic prompt fragments: header conventions + sandbox addendum."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
HEADER = """## Following Conventions
|
||||||
|
|
||||||
|
- Read files before editing — understand existing content before making changes.
|
||||||
|
- Mimic existing style, naming conventions, and patterns.
|
||||||
|
- Never claim a file was created/updated unless filesystem tool output confirms success.
|
||||||
|
- If a file write/edit fails, explicitly report the failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SANDBOX_ADDENDUM = (
|
||||||
|
"\n- execute_code: run Python code in an isolated sandbox."
|
||||||
|
"\n\n## Code Execution"
|
||||||
|
"\n\nUse execute_code whenever a task benefits from running code."
|
||||||
|
" Never perform arithmetic manually."
|
||||||
|
"\n\nDocuments here are XML-wrapped markdown, not raw data files."
|
||||||
|
" To work with them programmatically, read the document first,"
|
||||||
|
" extract the data, write it as a clean file (CSV, JSON, etc.),"
|
||||||
|
" and then run your code against it."
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Desktop-mode filesystem system prompt body."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
BODY = """
|
||||||
|
## Local Folder Mode
|
||||||
|
|
||||||
|
This chat operates directly on the user's local folders. Writes and edits
|
||||||
|
hit disk immediately — there is no end-of-turn staging, no `/documents/`
|
||||||
|
namespace, and no `temp_` semantics.
|
||||||
|
|
||||||
|
## Filesystem Tools
|
||||||
|
|
||||||
|
All file paths must start with `/` and use mount-prefixed absolute paths
|
||||||
|
like `/<mount>/file.ext`. Relative paths resolve against the current working
|
||||||
|
directory (`cwd`).
|
||||||
|
|
||||||
|
- ls(path, offset=0, limit=200): list files and directories at the given path.
|
||||||
|
- read_file(path, offset, limit): read a file (paginated) from disk.
|
||||||
|
- write_file(path, content): write a file to disk.
|
||||||
|
- edit_file(path, old, new): exact string-replacement edit on disk.
|
||||||
|
- glob(pattern, path): find files matching a glob pattern.
|
||||||
|
- grep(pattern, path, glob): substring search across files.
|
||||||
|
- mkdir(path): create a directory on disk.
|
||||||
|
- cd(path): change the current working directory.
|
||||||
|
- pwd(): print the current working directory.
|
||||||
|
- move_file(source, dest): move/rename a file.
|
||||||
|
- rm(path): delete a single file from disk (no `-r`). NOT reversible.
|
||||||
|
- rmdir(path): delete an empty directory from disk. NOT reversible.
|
||||||
|
- list_tree(path, max_depth, page_size): recursively list files/folders.
|
||||||
|
|
||||||
|
## Workflow Tips
|
||||||
|
|
||||||
|
- If you are unsure which mounts are available, call `ls('/')` first.
|
||||||
|
- For large trees, prefer `list_tree` then `grep` then `read_file` over
|
||||||
|
brute-force directory traversal.
|
||||||
|
- Cross-mount moves are not supported.
|
||||||
|
- Desktop deletes hit disk immediately and cannot be undone via the
|
||||||
|
agent's revert flow — confirm before calling `rm`/`rmdir`.
|
||||||
|
|
||||||
|
## Priority List
|
||||||
|
|
||||||
|
You may receive a `<priority_documents>` system message listing the top-K
|
||||||
|
documents from the user's SurfSense knowledge base — these are cloud-ingested
|
||||||
|
via connectors (Notion, Slack, etc.), not local files. Treat it as a hint:
|
||||||
|
consult it when the task spans both local and cloud sources (e.g. drafting a
|
||||||
|
local note from a Notion summary); skip when the task is purely about local
|
||||||
|
files.
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
"""Public assembly of the FS system prompt for a given session."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
|
||||||
|
from .cloud import BODY as CLOUD_BODY
|
||||||
|
from .common import HEADER, SANDBOX_ADDENDUM
|
||||||
|
from .desktop import BODY as DESKTOP_BODY
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_prompt(
|
||||||
|
mode: FilesystemMode, *, sandbox_available: bool
|
||||||
|
) -> str:
|
||||||
|
"""Assemble the FS prompt: common header + mode body + optional sandbox section."""
|
||||||
|
body = CLOUD_BODY if mode == FilesystemMode.CLOUD else DESKTOP_BODY
|
||||||
|
base = HEADER + body
|
||||||
|
if sandbox_available:
|
||||||
|
base += SANDBOX_ADDENDUM
|
||||||
|
return base
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Filesystem tool factories — one vertical slice per tool."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .cd import create_cd_tool
|
||||||
|
from .edit_file import create_edit_file_tool
|
||||||
|
from .execute_code import create_execute_code_tool
|
||||||
|
from .list_tree import create_list_tree_tool
|
||||||
|
from .ls import create_ls_tool
|
||||||
|
from .mkdir import create_mkdir_tool
|
||||||
|
from .move_file import create_move_file_tool
|
||||||
|
from .pwd import create_pwd_tool
|
||||||
|
from .read_file import create_read_file_tool
|
||||||
|
from .rm import create_rm_tool
|
||||||
|
from .rmdir import create_rmdir_tool
|
||||||
|
from .write_file import create_write_file_tool
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"create_cd_tool",
|
||||||
|
"create_edit_file_tool",
|
||||||
|
"create_execute_code_tool",
|
||||||
|
"create_list_tree_tool",
|
||||||
|
"create_ls_tool",
|
||||||
|
"create_mkdir_tool",
|
||||||
|
"create_move_file_tool",
|
||||||
|
"create_pwd_tool",
|
||||||
|
"create_read_file_tool",
|
||||||
|
"create_rm_tool",
|
||||||
|
"create_rmdir_tool",
|
||||||
|
"create_write_file_tool",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Tool: ``cd`` — change the current working directory (cwd)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .index import create_cd_tool
|
||||||
|
|
||||||
|
__all__ = ["create_cd_tool"]
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""Description string for ``cd`` (mode-agnostic)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
|
||||||
|
_DESCRIPTION = """Changes the current working directory (cwd).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- path: absolute or relative directory path. Relative paths resolve against
|
||||||
|
the current cwd.
|
||||||
|
|
||||||
|
The new cwd is used by other filesystem tools whenever a relative path is
|
||||||
|
given. Returns the resolved cwd.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def select_description(mode: FilesystemMode) -> str:
|
||||||
|
return _DESCRIPTION
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""``cd`` factory: resolve target, verify existence (staged + on-disk), update cwd."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Annotated
|
||||||
|
|
||||||
|
from deepagents.backends.utils import validate_path
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
from langchain_core.messages import ToolMessage
|
||||||
|
from langchain_core.tools import BaseTool, StructuredTool
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
from app.agents.new_chat.path_resolver import DOCUMENTS_ROOT
|
||||||
|
|
||||||
|
from ...middleware.async_dispatch import run_async_blocking
|
||||||
|
from ...middleware.path_resolution import resolve_relative
|
||||||
|
from .description import select_description
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def create_cd_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
|
||||||
|
description = select_description(mw._filesystem_mode)
|
||||||
|
|
||||||
|
async def async_cd(
|
||||||
|
path: Annotated[str, "Absolute or relative directory path to switch into."],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> Command | str:
|
||||||
|
target = resolve_relative(mw, path, runtime)
|
||||||
|
try:
|
||||||
|
validated = validate_path(target)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
backend = mw._get_backend(runtime)
|
||||||
|
try:
|
||||||
|
infos = await backend.als_info(validated)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
return f"Error: {exc}"
|
||||||
|
staged_dirs = list(runtime.state.get("staged_dirs") or [])
|
||||||
|
files = runtime.state.get("files") or {}
|
||||||
|
cwd_exists = (
|
||||||
|
bool(infos)
|
||||||
|
or validated in staged_dirs
|
||||||
|
or any(p == validated for p in files)
|
||||||
|
or any(
|
||||||
|
isinstance(p, str) and p.startswith(validated.rstrip("/") + "/")
|
||||||
|
for p in files
|
||||||
|
)
|
||||||
|
or validated == "/"
|
||||||
|
or validated == DOCUMENTS_ROOT
|
||||||
|
)
|
||||||
|
if not cwd_exists:
|
||||||
|
return f"Error: directory '{validated}' not found."
|
||||||
|
return Command(
|
||||||
|
update={
|
||||||
|
"cwd": validated,
|
||||||
|
"messages": [
|
||||||
|
ToolMessage(
|
||||||
|
content=f"cwd changed to {validated}",
|
||||||
|
tool_call_id=runtime.tool_call_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def sync_cd(
|
||||||
|
path: Annotated[str, "Absolute or relative directory path to switch into."],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> Command | str:
|
||||||
|
return run_async_blocking(async_cd(path, runtime))
|
||||||
|
|
||||||
|
return StructuredTool.from_function(
|
||||||
|
name="cd",
|
||||||
|
description=description,
|
||||||
|
func=sync_cd,
|
||||||
|
coroutine=async_cd,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Tool: ``edit_file`` — exact string replacement on a file."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .index import create_edit_file_tool
|
||||||
|
|
||||||
|
__all__ = ["create_edit_file_tool"]
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""Mode-specific description strings for ``edit_file``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
|
||||||
|
_CLOUD_DESCRIPTION = """Performs exact string replacements in files.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Read the file before editing.
|
||||||
|
- Preserve exact indentation and formatting.
|
||||||
|
- Edits to documents under `/documents/` are persisted at end of turn.
|
||||||
|
- Edits to `temp_*` files are discarded at end of turn.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_DESKTOP_DESCRIPTION = """Performs exact string replacements in files on disk.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Read the file before editing.
|
||||||
|
- Preserve exact indentation and formatting.
|
||||||
|
- Edits hit disk immediately.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def select_description(mode: FilesystemMode) -> str:
|
||||||
|
if mode == FilesystemMode.CLOUD:
|
||||||
|
return _CLOUD_DESCRIPTION
|
||||||
|
return _DESKTOP_DESCRIPTION
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
"""``edit_file`` factory: lazy-load KB doc, enforce cloud namespace, dispatch to backend."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Annotated, Any
|
||||||
|
|
||||||
|
from deepagents.backends.protocol import EditResult
|
||||||
|
from deepagents.backends.utils import validate_path
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
from langchain_core.messages import ToolMessage
|
||||||
|
from langchain_core.tools import BaseTool, StructuredTool
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
|
||||||
|
|
||||||
|
from ...middleware.async_dispatch import run_async_blocking
|
||||||
|
from ...middleware.mode import is_cloud
|
||||||
|
from ...middleware.namespace_policy import check_cloud_write_namespace
|
||||||
|
from ...middleware.path_resolution import resolve_relative
|
||||||
|
from .description import select_description
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def create_edit_file_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
|
||||||
|
description = select_description(mw._filesystem_mode)
|
||||||
|
|
||||||
|
async def async_edit_file(
|
||||||
|
file_path: Annotated[
|
||||||
|
str,
|
||||||
|
"Absolute path to the file to edit. Relative paths resolve against the current cwd.",
|
||||||
|
],
|
||||||
|
old_string: Annotated[
|
||||||
|
str,
|
||||||
|
"Exact text to replace. Must be unique unless replace_all is True.",
|
||||||
|
],
|
||||||
|
new_string: Annotated[
|
||||||
|
str,
|
||||||
|
"Replacement text. Must differ from old_string.",
|
||||||
|
],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
*,
|
||||||
|
replace_all: Annotated[
|
||||||
|
bool,
|
||||||
|
"If True, replace all occurrences of old_string. Defaults to False.",
|
||||||
|
] = False,
|
||||||
|
) -> Command | str:
|
||||||
|
target = resolve_relative(mw, file_path, runtime)
|
||||||
|
try:
|
||||||
|
validated = validate_path(target)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
namespace_error = check_cloud_write_namespace(mw, validated, runtime)
|
||||||
|
if namespace_error:
|
||||||
|
return namespace_error
|
||||||
|
|
||||||
|
backend = mw._get_backend(runtime)
|
||||||
|
files_state = runtime.state.get("files") or {}
|
||||||
|
doc_id_to_attach: int | None = None
|
||||||
|
|
||||||
|
if (
|
||||||
|
is_cloud(mw._filesystem_mode)
|
||||||
|
and validated not in files_state
|
||||||
|
and isinstance(backend, KBPostgresBackend)
|
||||||
|
):
|
||||||
|
loaded = await backend._load_file_data(validated)
|
||||||
|
if loaded is None:
|
||||||
|
return f"Error: File '{validated}' not found"
|
||||||
|
_, doc_id_to_attach = loaded
|
||||||
|
|
||||||
|
res: EditResult = await backend.aedit(
|
||||||
|
validated, old_string, new_string, replace_all=replace_all
|
||||||
|
)
|
||||||
|
if res.error:
|
||||||
|
return res.error
|
||||||
|
|
||||||
|
path = res.path or validated
|
||||||
|
files_update = res.files_update or {}
|
||||||
|
update: dict[str, Any] = {
|
||||||
|
"files": files_update,
|
||||||
|
"messages": [
|
||||||
|
ToolMessage(
|
||||||
|
content=(
|
||||||
|
f"Successfully replaced {res.occurrences} instance(s) "
|
||||||
|
f"of the string in '{path}'"
|
||||||
|
),
|
||||||
|
tool_call_id=runtime.tool_call_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if is_cloud(mw._filesystem_mode):
|
||||||
|
update["dirty_paths"] = [path]
|
||||||
|
update["dirty_path_tool_calls"] = {path: runtime.tool_call_id}
|
||||||
|
if doc_id_to_attach is not None:
|
||||||
|
update["doc_id_by_path"] = {path: doc_id_to_attach}
|
||||||
|
return Command(update=update)
|
||||||
|
|
||||||
|
def sync_edit_file(
|
||||||
|
file_path: Annotated[
|
||||||
|
str,
|
||||||
|
"Absolute path to the file to edit. Relative paths resolve against the current cwd.",
|
||||||
|
],
|
||||||
|
old_string: Annotated[
|
||||||
|
str,
|
||||||
|
"Exact text to replace. Must be unique unless replace_all is True.",
|
||||||
|
],
|
||||||
|
new_string: Annotated[
|
||||||
|
str,
|
||||||
|
"Replacement text. Must differ from old_string.",
|
||||||
|
],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
*,
|
||||||
|
replace_all: Annotated[
|
||||||
|
bool,
|
||||||
|
"If True, replace all occurrences of old_string. Defaults to False.",
|
||||||
|
] = False,
|
||||||
|
) -> Command | str:
|
||||||
|
return run_async_blocking(
|
||||||
|
async_edit_file(
|
||||||
|
file_path, old_string, new_string, runtime, replace_all=replace_all
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return StructuredTool.from_function(
|
||||||
|
name="edit_file",
|
||||||
|
description=description,
|
||||||
|
func=sync_edit_file,
|
||||||
|
coroutine=async_edit_file,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Tool: ``execute_code`` — run Python code in an isolated sandbox."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .index import create_execute_code_tool
|
||||||
|
|
||||||
|
__all__ = ["create_execute_code_tool"]
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""Description string for ``execute_code`` (mode-agnostic)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
|
||||||
|
_DESCRIPTION = """Executes Python code in an isolated sandbox environment.
|
||||||
|
|
||||||
|
Common data-science packages are pre-installed (pandas, numpy, matplotlib,
|
||||||
|
scipy, scikit-learn).
|
||||||
|
|
||||||
|
Usage notes:
|
||||||
|
- No outbound network access.
|
||||||
|
- Returns combined stdout/stderr with exit code.
|
||||||
|
- Use print() to produce output.
|
||||||
|
- Use the optional timeout parameter to override the default timeout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def select_description(mode: FilesystemMode) -> str:
|
||||||
|
return _DESCRIPTION
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""Sandbox-execution helpers for ``execute_code``.
|
||||||
|
|
||||||
|
Wraps user-supplied code in a heredoc and dispatches it to the Daytona
|
||||||
|
sandbox associated with the current chat thread, with a single retry on
|
||||||
|
sandbox failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from daytona.common.errors import DaytonaError
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
from app.agents.new_chat.sandbox import (
|
||||||
|
_evict_sandbox_cache,
|
||||||
|
delete_sandbox,
|
||||||
|
get_or_create_sandbox,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_EXECUTE_TIMEOUT = 300
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_as_python(code: str) -> str:
|
||||||
|
"""Wrap ``code`` in a unique-sentinel heredoc for shell execution."""
|
||||||
|
sentinel = f"_PYEOF_{secrets.token_hex(8)}"
|
||||||
|
return f"python3 << '{sentinel}'\n{code}\n{sentinel}"
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_in_sandbox(
|
||||||
|
mw: "SurfSenseFilesystemMiddleware",
|
||||||
|
command: str,
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
timeout: int | None,
|
||||||
|
) -> str:
|
||||||
|
"""Top-level entry: wraps + retries once on sandbox failure."""
|
||||||
|
assert mw._thread_id is not None
|
||||||
|
command = wrap_as_python(command)
|
||||||
|
try:
|
||||||
|
return await _try_sandbox_execute(mw, command, runtime, timeout)
|
||||||
|
except (DaytonaError, Exception) as first_err:
|
||||||
|
logger.warning(
|
||||||
|
"Sandbox execute failed for thread %s, retrying: %s",
|
||||||
|
mw._thread_id,
|
||||||
|
first_err,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await delete_sandbox(mw._thread_id)
|
||||||
|
except Exception:
|
||||||
|
_evict_sandbox_cache(mw._thread_id)
|
||||||
|
try:
|
||||||
|
return await _try_sandbox_execute(mw, command, runtime, timeout)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Sandbox retry also failed for thread %s", mw._thread_id
|
||||||
|
)
|
||||||
|
return "Error: Code execution is temporarily unavailable. Please try again."
|
||||||
|
|
||||||
|
|
||||||
|
async def _try_sandbox_execute(
|
||||||
|
mw: "SurfSenseFilesystemMiddleware",
|
||||||
|
command: str,
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
timeout: int | None,
|
||||||
|
) -> str:
|
||||||
|
"""One sandbox-execute attempt: get/create sandbox, run, format output."""
|
||||||
|
sandbox, _is_new = await get_or_create_sandbox(mw._thread_id)
|
||||||
|
result = await sandbox.aexecute(command, timeout=timeout)
|
||||||
|
output = (result.output or "").strip()
|
||||||
|
if not output and result.exit_code == 0:
|
||||||
|
return (
|
||||||
|
"[Code executed successfully but produced no output. "
|
||||||
|
"Use print() to display results, then try again.]"
|
||||||
|
)
|
||||||
|
parts = [result.output]
|
||||||
|
if result.exit_code is not None:
|
||||||
|
status = "succeeded" if result.exit_code == 0 else "failed"
|
||||||
|
parts.append(f"\n[Command {status} with exit code {result.exit_code}]")
|
||||||
|
if result.truncated:
|
||||||
|
parts.append("\n[Output was truncated due to size limits]")
|
||||||
|
return "".join(parts)
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""``execute_code`` factory: bounds-check timeout, dispatch to the sandbox."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Annotated
|
||||||
|
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
from langchain_core.tools import BaseTool, StructuredTool
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
|
||||||
|
from ...middleware.async_dispatch import run_async_blocking
|
||||||
|
from .description import select_description
|
||||||
|
from .helpers import MAX_EXECUTE_TIMEOUT, execute_in_sandbox
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def create_execute_code_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
|
||||||
|
description = select_description(mw._filesystem_mode)
|
||||||
|
|
||||||
|
def sync_execute_code(
|
||||||
|
command: Annotated[
|
||||||
|
str, "Python code to execute. Use print() to see output."
|
||||||
|
],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
timeout: Annotated[
|
||||||
|
int | None,
|
||||||
|
"Optional timeout in seconds.",
|
||||||
|
] = None,
|
||||||
|
) -> str:
|
||||||
|
if timeout is not None:
|
||||||
|
if timeout < 0:
|
||||||
|
return f"Error: timeout must be non-negative, got {timeout}."
|
||||||
|
if timeout > MAX_EXECUTE_TIMEOUT:
|
||||||
|
return f"Error: timeout {timeout}s exceeds maximum ({MAX_EXECUTE_TIMEOUT}s)."
|
||||||
|
return run_async_blocking(
|
||||||
|
execute_in_sandbox(mw, command, runtime, timeout)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_execute_code(
|
||||||
|
command: Annotated[
|
||||||
|
str, "Python code to execute. Use print() to see output."
|
||||||
|
],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
timeout: Annotated[
|
||||||
|
int | None,
|
||||||
|
"Optional timeout in seconds.",
|
||||||
|
] = None,
|
||||||
|
) -> str:
|
||||||
|
if timeout is not None:
|
||||||
|
if timeout < 0:
|
||||||
|
return f"Error: timeout must be non-negative, got {timeout}."
|
||||||
|
if timeout > MAX_EXECUTE_TIMEOUT:
|
||||||
|
return f"Error: timeout {timeout}s exceeds maximum ({MAX_EXECUTE_TIMEOUT}s)."
|
||||||
|
return await execute_in_sandbox(mw, command, runtime, timeout)
|
||||||
|
|
||||||
|
return StructuredTool.from_function(
|
||||||
|
name="execute_code",
|
||||||
|
description=description,
|
||||||
|
func=sync_execute_code,
|
||||||
|
coroutine=async_execute_code,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Tool: ``glob`` — description override (the tool comes from the base middleware)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .description import select_description
|
||||||
|
|
||||||
|
__all__ = ["select_description"]
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
"""Description string for ``glob`` (mode-agnostic)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
|
||||||
|
_DESCRIPTION = """Find files matching a glob pattern.
|
||||||
|
|
||||||
|
Supports standard glob patterns: `*`, `**`, `?`.
|
||||||
|
Returns absolute file paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def select_description(mode: FilesystemMode) -> str:
|
||||||
|
return _DESCRIPTION
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Tool: ``grep`` — description override (the tool comes from the base middleware)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .description import select_description
|
||||||
|
|
||||||
|
__all__ = ["select_description"]
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""Mode-specific description strings for ``grep``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
|
||||||
|
_CLOUD_DESCRIPTION = """Search for a literal text pattern across files.
|
||||||
|
|
||||||
|
Searches both your in-memory edits and the indexed chunks in Postgres.
|
||||||
|
State-cached file matches include real line numbers; database hits return
|
||||||
|
`line=0` because their position depends on per-document XML layout — call
|
||||||
|
`read_file(path)` afterwards to find the exact line.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_DESKTOP_DESCRIPTION = """Search for a literal text pattern across files.
|
||||||
|
|
||||||
|
Searches files on disk and any in-memory edits. Returns real line numbers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def select_description(mode: FilesystemMode) -> str:
|
||||||
|
if mode == FilesystemMode.CLOUD:
|
||||||
|
return _CLOUD_DESCRIPTION
|
||||||
|
return _DESKTOP_DESCRIPTION
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Tool: ``list_tree`` — recursively list files / folders in one bounded call."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .index import create_list_tree_tool
|
||||||
|
|
||||||
|
__all__ = ["create_list_tree_tool"]
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""Mode-specific description strings for ``list_tree``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
|
||||||
|
_CLOUD_DESCRIPTION = """Lists files/folders recursively in a single bounded call.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- path: absolute path to start from. Defaults to `/documents`.
|
||||||
|
- max_depth: recursion depth limit (default 8).
|
||||||
|
- page_size: maximum number of entries returned (max 1000).
|
||||||
|
- include_files / include_dirs: filter returned entry types.
|
||||||
|
|
||||||
|
Returns JSON with:
|
||||||
|
- entries: [{path, is_dir, size, modified_at, depth}]
|
||||||
|
- truncated: true when additional entries were omitted due to page_size.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_DESKTOP_DESCRIPTION = """Lists files/folders recursively in a single bounded call.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- path: absolute path to start from. Defaults to `/`.
|
||||||
|
- max_depth: recursion depth limit (default 8).
|
||||||
|
- page_size: maximum number of entries returned (max 1000).
|
||||||
|
- include_files / include_dirs: filter returned entry types.
|
||||||
|
|
||||||
|
Returns JSON with:
|
||||||
|
- entries: [{path, is_dir, size, modified_at, depth}]
|
||||||
|
- truncated: true when additional entries were omitted due to page_size.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def select_description(mode: FilesystemMode) -> str:
|
||||||
|
if mode == FilesystemMode.CLOUD:
|
||||||
|
return _CLOUD_DESCRIPTION
|
||||||
|
return _DESKTOP_DESCRIPTION
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
"""``list_tree`` factory: bounded recursive listing across cloud / desktop backends."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING, Annotated
|
||||||
|
|
||||||
|
from deepagents.backends.utils import validate_path
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
from langchain_core.tools import BaseTool, StructuredTool
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
|
||||||
|
|
||||||
|
from ...middleware.async_dispatch import run_async_blocking
|
||||||
|
from ...middleware.path_resolution import resolve_list_target_path
|
||||||
|
from .description import select_description
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def create_list_tree_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
|
||||||
|
description = select_description(mw._filesystem_mode)
|
||||||
|
|
||||||
|
async def async_list_tree(
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
path: Annotated[
|
||||||
|
str,
|
||||||
|
"Absolute path to start from. Defaults to /documents in cloud mode.",
|
||||||
|
] = "",
|
||||||
|
max_depth: Annotated[int, "Recursion depth limit. Default 8."] = 8,
|
||||||
|
page_size: Annotated[int, "Maximum entries returned. Max 1000."] = 500,
|
||||||
|
include_files: Annotated[bool, "Include file entries."] = True,
|
||||||
|
include_dirs: Annotated[bool, "Include directory entries."] = True,
|
||||||
|
) -> str:
|
||||||
|
if max_depth < 0:
|
||||||
|
return "Error: max_depth must be >= 0."
|
||||||
|
if page_size < 1:
|
||||||
|
return "Error: page_size must be >= 1."
|
||||||
|
if not include_files and not include_dirs:
|
||||||
|
return "Error: include_files and include_dirs cannot both be false."
|
||||||
|
|
||||||
|
target = resolve_list_target_path(mw, path, runtime)
|
||||||
|
try:
|
||||||
|
validated = validate_path(target)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
backend = mw._get_backend(runtime)
|
||||||
|
if isinstance(backend, KBPostgresBackend):
|
||||||
|
result = await backend.alist_tree_listing(
|
||||||
|
validated,
|
||||||
|
max_depth=max_depth,
|
||||||
|
page_size=page_size,
|
||||||
|
include_files=include_files,
|
||||||
|
include_dirs=include_dirs,
|
||||||
|
)
|
||||||
|
elif hasattr(backend, "alist_tree"):
|
||||||
|
result = await backend.alist_tree(
|
||||||
|
validated,
|
||||||
|
max_depth=max_depth,
|
||||||
|
page_size=page_size,
|
||||||
|
include_files=include_files,
|
||||||
|
include_dirs=include_dirs,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return "Error: list_tree is not supported by the active backend."
|
||||||
|
|
||||||
|
if isinstance(result, dict) and isinstance(result.get("error"), str):
|
||||||
|
return result["error"]
|
||||||
|
return json.dumps(result, ensure_ascii=True)
|
||||||
|
|
||||||
|
def sync_list_tree(
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
path: Annotated[
|
||||||
|
str,
|
||||||
|
"Absolute path to start from. Defaults to /documents in cloud mode.",
|
||||||
|
] = "",
|
||||||
|
max_depth: Annotated[int, "Recursion depth limit. Default 8."] = 8,
|
||||||
|
page_size: Annotated[int, "Maximum entries returned. Max 1000."] = 500,
|
||||||
|
include_files: Annotated[bool, "Include file entries."] = True,
|
||||||
|
include_dirs: Annotated[bool, "Include directory entries."] = True,
|
||||||
|
) -> str:
|
||||||
|
return run_async_blocking(
|
||||||
|
async_list_tree(
|
||||||
|
runtime,
|
||||||
|
path=path,
|
||||||
|
max_depth=max_depth,
|
||||||
|
page_size=page_size,
|
||||||
|
include_files=include_files,
|
||||||
|
include_dirs=include_dirs,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return StructuredTool.from_function(
|
||||||
|
name="list_tree",
|
||||||
|
description=description,
|
||||||
|
func=sync_list_tree,
|
||||||
|
coroutine=async_list_tree,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Tool: ``ls`` — list files and directories at a path."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .index import create_ls_tool
|
||||||
|
|
||||||
|
__all__ = ["create_ls_tool"]
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Mode-specific description strings for ``ls``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
|
||||||
|
_CLOUD_DESCRIPTION = """Lists files and directories at the given path.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
- Provide an absolute path under `/documents` (relative paths resolve under
|
||||||
|
the current cwd, which defaults to `/documents`).
|
||||||
|
- For very large folders, use `offset` and `limit` to paginate the listing.
|
||||||
|
- Returns one entry per line; directories end with a trailing `/`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_DESKTOP_DESCRIPTION = """Lists files and directories at the given path.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
- Provide an absolute path using a mount prefix (e.g. `/<mount>/sub/dir`).
|
||||||
|
Use `ls('/')` to discover available mounts.
|
||||||
|
- For very large folders, use `offset` and `limit` to paginate the listing.
|
||||||
|
- Returns one entry per line; directories end with a trailing `/`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def select_description(mode: FilesystemMode) -> str:
|
||||||
|
if mode == FilesystemMode.CLOUD:
|
||||||
|
return _CLOUD_DESCRIPTION
|
||||||
|
return _DESKTOP_DESCRIPTION
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""``ls`` factory: resolve target, page through backend listing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Annotated
|
||||||
|
|
||||||
|
from deepagents.backends.utils import validate_path
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
from langchain_core.tools import BaseTool, StructuredTool
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
from app.agents.new_chat.middleware.kb_postgres_backend import paginate_listing
|
||||||
|
|
||||||
|
from ...middleware.async_dispatch import run_async_blocking
|
||||||
|
from ...middleware.path_resolution import resolve_list_target_path
|
||||||
|
from .description import select_description
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def create_ls_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
|
||||||
|
description = select_description(mw._filesystem_mode)
|
||||||
|
|
||||||
|
async def async_ls(
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
path: Annotated[
|
||||||
|
str,
|
||||||
|
"Absolute path to the directory to list. Relative paths resolve against the current cwd.",
|
||||||
|
] = "",
|
||||||
|
offset: Annotated[
|
||||||
|
int,
|
||||||
|
"Number of entries to skip. Use for paginating large folders. Defaults to 0.",
|
||||||
|
] = 0,
|
||||||
|
limit: Annotated[
|
||||||
|
int,
|
||||||
|
"Maximum number of entries to return. Defaults to 200.",
|
||||||
|
] = 200,
|
||||||
|
) -> str:
|
||||||
|
target = resolve_list_target_path(mw, path, runtime)
|
||||||
|
try:
|
||||||
|
validated = validate_path(target)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
if offset < 0:
|
||||||
|
offset = 0
|
||||||
|
if limit < 1:
|
||||||
|
limit = 1
|
||||||
|
backend = mw._get_backend(runtime)
|
||||||
|
infos = await backend.als_info(validated)
|
||||||
|
page = paginate_listing(infos, offset=offset, limit=limit)
|
||||||
|
paths = [
|
||||||
|
f"{fi.get('path', '')}/" if fi.get("is_dir") else fi.get("path", "")
|
||||||
|
for fi in page
|
||||||
|
]
|
||||||
|
total = len(infos)
|
||||||
|
shown = len(page)
|
||||||
|
header = (
|
||||||
|
f"{validated} ({shown} of {total} entries"
|
||||||
|
f"{f', offset={offset}' if offset else ''})"
|
||||||
|
)
|
||||||
|
if not paths:
|
||||||
|
return f"{header}\n(empty)"
|
||||||
|
body = "\n".join(paths)
|
||||||
|
if total > offset + shown:
|
||||||
|
body += (
|
||||||
|
f"\n... {total - offset - shown} more — call ls("
|
||||||
|
f"'{validated}', offset={offset + shown}, limit={limit})"
|
||||||
|
)
|
||||||
|
return f"{header}\n{body}"
|
||||||
|
|
||||||
|
def sync_ls(
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
path: Annotated[
|
||||||
|
str,
|
||||||
|
"Absolute path to the directory to list. Relative paths resolve against the current cwd.",
|
||||||
|
] = "",
|
||||||
|
offset: Annotated[
|
||||||
|
int,
|
||||||
|
"Number of entries to skip. Use for paginating large folders. Defaults to 0.",
|
||||||
|
] = 0,
|
||||||
|
limit: Annotated[
|
||||||
|
int,
|
||||||
|
"Maximum number of entries to return. Defaults to 200.",
|
||||||
|
] = 200,
|
||||||
|
) -> str:
|
||||||
|
return run_async_blocking(
|
||||||
|
async_ls(runtime, path=path, offset=offset, limit=limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
return StructuredTool.from_function(
|
||||||
|
name="ls",
|
||||||
|
description=description,
|
||||||
|
func=sync_ls,
|
||||||
|
coroutine=async_ls,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Tool: ``mkdir`` — create a directory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .index import create_mkdir_tool
|
||||||
|
|
||||||
|
__all__ = ["create_mkdir_tool"]
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""Mode-specific description strings for ``mkdir``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
|
||||||
|
_CLOUD_DESCRIPTION = """Creates a directory under `/documents/`.
|
||||||
|
|
||||||
|
Stages the folder for end-of-turn commit; the Folder row is inserted only
|
||||||
|
after the agent's turn finishes successfully.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- path: absolute path of the new directory (must start with
|
||||||
|
`/documents/`).
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Parent folders are created as needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_DESKTOP_DESCRIPTION = """Creates a directory on disk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- path: absolute mount-prefixed path of the new directory.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Parent folders are created as needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def select_description(mode: FilesystemMode) -> str:
|
||||||
|
if mode == FilesystemMode.CLOUD:
|
||||||
|
return _CLOUD_DESCRIPTION
|
||||||
|
return _DESKTOP_DESCRIPTION
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""``mkdir`` factory: cloud stages for end-of-turn; desktop hits disk immediately."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import TYPE_CHECKING, Annotated, Any
|
||||||
|
|
||||||
|
from deepagents.backends.utils import validate_path
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
from langchain_core.messages import ToolMessage
|
||||||
|
from langchain_core.tools import BaseTool, StructuredTool
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
from app.agents.new_chat.path_resolver import DOCUMENTS_ROOT
|
||||||
|
|
||||||
|
from ...middleware.async_dispatch import run_async_blocking
|
||||||
|
from ...middleware.mode import is_cloud
|
||||||
|
from ...middleware.path_resolution import resolve_relative
|
||||||
|
from .description import select_description
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def create_mkdir_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
|
||||||
|
description = select_description(mw._filesystem_mode)
|
||||||
|
|
||||||
|
async def async_mkdir(
|
||||||
|
path: Annotated[str, "Absolute or relative directory path to create."],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> Command | str:
|
||||||
|
target = resolve_relative(mw, path, runtime)
|
||||||
|
try:
|
||||||
|
validated = validate_path(target)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
if is_cloud(mw._filesystem_mode):
|
||||||
|
if not (
|
||||||
|
validated.startswith(DOCUMENTS_ROOT + "/")
|
||||||
|
or validated == DOCUMENTS_ROOT
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
"Error: cloud mkdir must target a path under /documents/ "
|
||||||
|
f"(got '{validated}')."
|
||||||
|
)
|
||||||
|
return Command(
|
||||||
|
update={
|
||||||
|
"staged_dirs": [validated],
|
||||||
|
"staged_dir_tool_calls": {
|
||||||
|
validated: runtime.tool_call_id,
|
||||||
|
},
|
||||||
|
"messages": [
|
||||||
|
ToolMessage(
|
||||||
|
content=(
|
||||||
|
f"Staged directory '{validated}' (will be created "
|
||||||
|
"at end of turn)."
|
||||||
|
),
|
||||||
|
tool_call_id=runtime.tool_call_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = mw._get_backend(runtime)
|
||||||
|
local_method = getattr(backend, "amkdir", None) or getattr(
|
||||||
|
backend, "mkdir", None
|
||||||
|
)
|
||||||
|
if callable(local_method):
|
||||||
|
try:
|
||||||
|
res: Any = local_method(validated, parents=True, exist_ok=True)
|
||||||
|
if asyncio.iscoroutine(res):
|
||||||
|
await res
|
||||||
|
except TypeError:
|
||||||
|
res = local_method(validated)
|
||||||
|
if asyncio.iscoroutine(res):
|
||||||
|
await res
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
return f"Error: {exc}"
|
||||||
|
return f"Created directory {validated}"
|
||||||
|
|
||||||
|
def sync_mkdir(
|
||||||
|
path: Annotated[str, "Absolute or relative directory path to create."],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> Command | str:
|
||||||
|
return run_async_blocking(async_mkdir(path, runtime))
|
||||||
|
|
||||||
|
return StructuredTool.from_function(
|
||||||
|
name="mkdir",
|
||||||
|
description=description,
|
||||||
|
func=sync_mkdir,
|
||||||
|
coroutine=async_mkdir,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Tool: ``move_file`` — move or rename a file."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .index import create_move_file_tool
|
||||||
|
|
||||||
|
__all__ = ["create_move_file_tool"]
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""Mode-specific description strings for ``move_file``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
|
||||||
|
_CLOUD_DESCRIPTION = """Moves or renames a file or folder.
|
||||||
|
|
||||||
|
Use absolute paths for both source and destination.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `move_file` is staged this turn and committed at end of turn.
|
||||||
|
- The agent cannot overwrite an existing destination — pass a fresh dest
|
||||||
|
path or move the existing destination away first.
|
||||||
|
- The anonymous uploaded document is read-only and cannot be moved.
|
||||||
|
- Rename is a special case of move (same folder, different filename).
|
||||||
|
"""
|
||||||
|
|
||||||
|
_DESKTOP_DESCRIPTION = """Moves or renames a file or folder on disk.
|
||||||
|
|
||||||
|
Use mount-prefixed absolute paths for both source and destination
|
||||||
|
(e.g. `/<mount>/old.txt` -> `/<mount>/new.txt`).
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Cross-mount moves are not supported.
|
||||||
|
- Rename is a special case of move (same folder, different filename).
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def select_description(mode: FilesystemMode) -> str:
|
||||||
|
if mode == FilesystemMode.CLOUD:
|
||||||
|
return _CLOUD_DESCRIPTION
|
||||||
|
return _DESKTOP_DESCRIPTION
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""Cloud-mode move helper: stages source/dest into pending_moves + files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
from langchain_core.messages import ToolMessage
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
|
||||||
|
from app.agents.new_chat.path_resolver import DOCUMENTS_ROOT
|
||||||
|
from app.agents.new_chat.state_reducers import _CLEAR
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
async def cloud_move_file(
|
||||||
|
mw: "SurfSenseFilesystemMiddleware",
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
source: str,
|
||||||
|
dest: str,
|
||||||
|
*,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> Command | str:
|
||||||
|
"""Stage a source/dest move in cloud mode (commit at end of turn)."""
|
||||||
|
backend = mw._get_backend(runtime)
|
||||||
|
if not isinstance(backend, KBPostgresBackend):
|
||||||
|
return "Error: cloud move requires KBPostgresBackend."
|
||||||
|
|
||||||
|
if source == dest:
|
||||||
|
return f"Moved '{source}' to '{dest}' (no-op)"
|
||||||
|
if overwrite:
|
||||||
|
return (
|
||||||
|
"Error: overwrite=True is not supported in cloud mode. Move/edit "
|
||||||
|
"the destination doc explicitly first."
|
||||||
|
)
|
||||||
|
if not source.startswith(DOCUMENTS_ROOT + "/"):
|
||||||
|
return (
|
||||||
|
"Error: cloud move_file source must be under /documents/ (got "
|
||||||
|
f"'{source}')."
|
||||||
|
)
|
||||||
|
if not dest.startswith(DOCUMENTS_ROOT + "/"):
|
||||||
|
return (
|
||||||
|
"Error: cloud move_file destination must be under /documents/ (got "
|
||||||
|
f"'{dest}')."
|
||||||
|
)
|
||||||
|
anon = runtime.state.get("kb_anon_doc") or {}
|
||||||
|
if isinstance(anon, dict):
|
||||||
|
anon_path = str(anon.get("path") or "")
|
||||||
|
if anon_path and (anon_path in (source, dest)):
|
||||||
|
return "Error: the anonymous uploaded document is read-only."
|
||||||
|
|
||||||
|
files = runtime.state.get("files") or {}
|
||||||
|
doc_id_by_path = runtime.state.get("doc_id_by_path") or {}
|
||||||
|
pending_moves = list(runtime.state.get("pending_moves") or [])
|
||||||
|
|
||||||
|
if dest in files:
|
||||||
|
return f"Error: destination '{dest}' already exists."
|
||||||
|
if any(move.get("dest") == dest for move in pending_moves):
|
||||||
|
return f"Error: destination '{dest}' already exists."
|
||||||
|
if dest != source:
|
||||||
|
existing_dest = await backend._load_file_data(dest)
|
||||||
|
if existing_dest is not None:
|
||||||
|
return f"Error: destination '{dest}' already exists."
|
||||||
|
|
||||||
|
source_file_data = files.get(source)
|
||||||
|
source_doc_id = doc_id_by_path.get(source)
|
||||||
|
if source_file_data is None:
|
||||||
|
loaded = await backend._load_file_data(source)
|
||||||
|
if loaded is None:
|
||||||
|
return f"Error: source '{source}' not found."
|
||||||
|
source_file_data, loaded_doc_id = loaded
|
||||||
|
if source_doc_id is None:
|
||||||
|
source_doc_id = loaded_doc_id
|
||||||
|
|
||||||
|
files_update: dict[str, Any] = {source: None, dest: source_file_data}
|
||||||
|
update: dict[str, Any] = {
|
||||||
|
"files": files_update,
|
||||||
|
"pending_moves": [
|
||||||
|
{
|
||||||
|
"source": source,
|
||||||
|
"dest": dest,
|
||||||
|
"overwrite": False,
|
||||||
|
"tool_call_id": runtime.tool_call_id,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"messages": [
|
||||||
|
ToolMessage(
|
||||||
|
content=(
|
||||||
|
f"Moved '{source}' to '{dest}' (will commit at end of turn)."
|
||||||
|
),
|
||||||
|
tool_call_id=runtime.tool_call_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
doc_id_update: dict[str, int | None] = {source: None}
|
||||||
|
if source_doc_id is not None:
|
||||||
|
doc_id_update[dest] = source_doc_id
|
||||||
|
update["doc_id_by_path"] = doc_id_update
|
||||||
|
|
||||||
|
dirty_paths = list(runtime.state.get("dirty_paths") or [])
|
||||||
|
if source in dirty_paths:
|
||||||
|
new_dirty: list[Any] = [_CLEAR]
|
||||||
|
for entry in dirty_paths:
|
||||||
|
new_dirty.append(dest if entry == source else entry)
|
||||||
|
update["dirty_paths"] = new_dirty
|
||||||
|
return Command(update=update)
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""``move_file`` factory: dispatches cloud (staged) vs desktop (direct disk) moves."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Annotated, Any
|
||||||
|
|
||||||
|
from deepagents.backends.protocol import WriteResult
|
||||||
|
from deepagents.backends.utils import validate_path
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
from langchain_core.messages import ToolMessage
|
||||||
|
from langchain_core.tools import BaseTool, StructuredTool
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
|
||||||
|
from ...middleware.async_dispatch import run_async_blocking
|
||||||
|
from ...middleware.mode import is_cloud
|
||||||
|
from ...middleware.path_resolution import resolve_move_target_path
|
||||||
|
from .description import select_description
|
||||||
|
from .helpers import cloud_move_file
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def create_move_file_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
|
||||||
|
description = select_description(mw._filesystem_mode)
|
||||||
|
|
||||||
|
async def async_move_file(
|
||||||
|
source_path: Annotated[str, "Absolute or relative source path."],
|
||||||
|
destination_path: Annotated[str, "Absolute or relative destination path."],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
*,
|
||||||
|
overwrite: Annotated[
|
||||||
|
bool,
|
||||||
|
"If True, replace existing destination. Cloud mode rejects True. Defaults to False.",
|
||||||
|
] = False,
|
||||||
|
) -> Command | str:
|
||||||
|
if not source_path.strip() or not destination_path.strip():
|
||||||
|
return "Error: source_path and destination_path are required."
|
||||||
|
|
||||||
|
source = resolve_move_target_path(mw, source_path, runtime)
|
||||||
|
dest = resolve_move_target_path(mw, destination_path, runtime)
|
||||||
|
try:
|
||||||
|
validated_source = validate_path(source)
|
||||||
|
validated_dest = validate_path(dest)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
if is_cloud(mw._filesystem_mode):
|
||||||
|
return await cloud_move_file(
|
||||||
|
mw,
|
||||||
|
runtime,
|
||||||
|
validated_source,
|
||||||
|
validated_dest,
|
||||||
|
overwrite=overwrite,
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = mw._get_backend(runtime)
|
||||||
|
res: WriteResult = await backend.amove(
|
||||||
|
validated_source, validated_dest, overwrite=overwrite
|
||||||
|
)
|
||||||
|
if res.error:
|
||||||
|
return res.error
|
||||||
|
update: dict[str, Any] = {
|
||||||
|
"messages": [
|
||||||
|
ToolMessage(
|
||||||
|
content=f"Moved '{validated_source}' to '{res.path or validated_dest}'",
|
||||||
|
tool_call_id=runtime.tool_call_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if res.files_update is not None:
|
||||||
|
update["files"] = res.files_update
|
||||||
|
return Command(update=update)
|
||||||
|
|
||||||
|
def sync_move_file(
|
||||||
|
source_path: Annotated[str, "Absolute or relative source path."],
|
||||||
|
destination_path: Annotated[str, "Absolute or relative destination path."],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
*,
|
||||||
|
overwrite: Annotated[
|
||||||
|
bool,
|
||||||
|
"If True, replace existing destination. Cloud mode rejects True. Defaults to False.",
|
||||||
|
] = False,
|
||||||
|
) -> Command | str:
|
||||||
|
return run_async_blocking(
|
||||||
|
async_move_file(
|
||||||
|
source_path, destination_path, runtime, overwrite=overwrite
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return StructuredTool.from_function(
|
||||||
|
name="move_file",
|
||||||
|
description=description,
|
||||||
|
func=sync_move_file,
|
||||||
|
coroutine=async_move_file,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Tool: ``pwd`` — print the current working directory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .index import create_pwd_tool
|
||||||
|
|
||||||
|
__all__ = ["create_pwd_tool"]
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
"""Description string for ``pwd`` (mode-agnostic)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
|
||||||
|
_DESCRIPTION = """Prints the current working directory."""
|
||||||
|
|
||||||
|
|
||||||
|
def select_description(mode: FilesystemMode) -> str:
|
||||||
|
return _DESCRIPTION
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""``pwd`` factory: read the cwd from state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
from langchain_core.tools import BaseTool, StructuredTool
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
|
||||||
|
from ...middleware.path_resolution import current_cwd
|
||||||
|
from .description import select_description
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def create_pwd_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
|
||||||
|
description = select_description(mw._filesystem_mode)
|
||||||
|
|
||||||
|
def sync_pwd(
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> str:
|
||||||
|
return current_cwd(mw, runtime)
|
||||||
|
|
||||||
|
async def async_pwd(
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> str:
|
||||||
|
return current_cwd(mw, runtime)
|
||||||
|
|
||||||
|
return StructuredTool.from_function(
|
||||||
|
name="pwd",
|
||||||
|
description=description,
|
||||||
|
func=sync_pwd,
|
||||||
|
coroutine=async_pwd,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Tool: ``read_file`` — read a file (paginated) from the filesystem."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .index import create_read_file_tool
|
||||||
|
|
||||||
|
__all__ = ["create_read_file_tool"]
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Description string for ``read_file`` (mode-agnostic)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
|
||||||
|
_DESCRIPTION = """Reads a file from the filesystem.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
- By default, reads up to 100 lines from the beginning.
|
||||||
|
- Use `offset` and `limit` for pagination when files are large.
|
||||||
|
- Results include line numbers.
|
||||||
|
- Documents contain a `<chunk_index>` near the top listing every chunk with
|
||||||
|
its line range and a `matched="true"` flag for search-relevant chunks.
|
||||||
|
Read the index first, then jump to matched chunks with
|
||||||
|
`read_file(path, offset=<start_line>, limit=<num_lines>)`.
|
||||||
|
- Use chunk IDs (`<chunk id='...'>`) as citations in answers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def select_description(mode: FilesystemMode) -> str:
|
||||||
|
return _DESCRIPTION
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""``read_file`` factory: state-cache lookup, then lazy KB load, then disk read."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Annotated, Any
|
||||||
|
|
||||||
|
from deepagents.backends.utils import format_read_response, validate_path
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
from langchain_core.messages import ToolMessage
|
||||||
|
from langchain_core.tools import BaseTool, StructuredTool
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
|
||||||
|
|
||||||
|
from ...middleware.async_dispatch import run_async_blocking
|
||||||
|
from ...middleware.path_resolution import resolve_relative
|
||||||
|
from .description import select_description
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def create_read_file_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
|
||||||
|
description = select_description(mw._filesystem_mode)
|
||||||
|
|
||||||
|
async def async_read_file(
|
||||||
|
file_path: Annotated[
|
||||||
|
str,
|
||||||
|
"Absolute path to the file to read. Relative paths resolve against the current cwd.",
|
||||||
|
],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
offset: Annotated[
|
||||||
|
int,
|
||||||
|
"Line number to start reading from (0-indexed).",
|
||||||
|
] = 0,
|
||||||
|
limit: Annotated[
|
||||||
|
int,
|
||||||
|
"Maximum number of lines to read.",
|
||||||
|
] = 100,
|
||||||
|
) -> Command | str:
|
||||||
|
target = resolve_relative(mw, file_path, runtime)
|
||||||
|
try:
|
||||||
|
validated = validate_path(target)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
files = runtime.state.get("files") or {}
|
||||||
|
if validated in files:
|
||||||
|
return format_read_response(files[validated], offset, limit)
|
||||||
|
|
||||||
|
backend = mw._get_backend(runtime)
|
||||||
|
if isinstance(backend, KBPostgresBackend):
|
||||||
|
loaded = await backend._load_file_data(validated)
|
||||||
|
if loaded is None:
|
||||||
|
return f"Error: File '{validated}' not found"
|
||||||
|
file_data, doc_id = loaded
|
||||||
|
rendered = format_read_response(file_data, offset, limit)
|
||||||
|
update: dict[str, Any] = {
|
||||||
|
"files": {validated: file_data},
|
||||||
|
"messages": [
|
||||||
|
ToolMessage(
|
||||||
|
content=rendered,
|
||||||
|
tool_call_id=runtime.tool_call_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if doc_id is not None:
|
||||||
|
update["doc_id_by_path"] = {validated: doc_id}
|
||||||
|
return Command(update=update)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rendered = await backend.aread(validated, offset=offset, limit=limit)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
return f"Error: {exc}"
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
def sync_read_file(
|
||||||
|
file_path: Annotated[
|
||||||
|
str,
|
||||||
|
"Absolute path to the file to read. Relative paths resolve against the current cwd.",
|
||||||
|
],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
offset: Annotated[
|
||||||
|
int,
|
||||||
|
"Line number to start reading from (0-indexed).",
|
||||||
|
] = 0,
|
||||||
|
limit: Annotated[
|
||||||
|
int,
|
||||||
|
"Maximum number of lines to read.",
|
||||||
|
] = 100,
|
||||||
|
) -> Command | str:
|
||||||
|
return run_async_blocking(
|
||||||
|
async_read_file(file_path, runtime, offset, limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
return StructuredTool.from_function(
|
||||||
|
name="read_file",
|
||||||
|
description=description,
|
||||||
|
func=sync_read_file,
|
||||||
|
coroutine=async_read_file,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Tool: ``rm`` — delete a single file."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .index import create_rm_tool
|
||||||
|
|
||||||
|
__all__ = ["create_rm_tool"]
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Mode-specific description strings for ``rm``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
|
||||||
|
_CLOUD_DESCRIPTION = """Deletes a single file under `/documents/`.
|
||||||
|
|
||||||
|
Mirrors POSIX `rm path` (no `-r`, no glob expansion). Stages the deletion
|
||||||
|
for end-of-turn commit; the row is removed only after the agent's turn
|
||||||
|
finishes successfully.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- path: absolute or relative file path. Cannot point at a directory — use
|
||||||
|
`rmdir` for empty folders. Cannot target the root or `/documents`.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The action is reversible via the per-action revert flow when action
|
||||||
|
logging is enabled.
|
||||||
|
- The anonymous uploaded document is read-only and cannot be deleted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_DESKTOP_DESCRIPTION = """Deletes a single file from disk.
|
||||||
|
|
||||||
|
Mirrors POSIX `rm path` (no `-r`, no glob expansion). The deletion hits
|
||||||
|
disk immediately. Desktop deletes are NOT reversible via the agent's
|
||||||
|
revert flow.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- path: absolute mount-prefixed file path. Cannot point at a directory —
|
||||||
|
use `rmdir` for empty folders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def select_description(mode: FilesystemMode) -> str:
|
||||||
|
if mode == FilesystemMode.CLOUD:
|
||||||
|
return _CLOUD_DESCRIPTION
|
||||||
|
return _DESKTOP_DESCRIPTION
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
"""Cloud and desktop ``rm`` branches.
|
||||||
|
|
||||||
|
Both branches receive an already-resolved + validated absolute path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from deepagents.backends.protocol import WriteResult
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
from langchain_core.messages import ToolMessage
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
|
||||||
|
from app.agents.new_chat.path_resolver import DOCUMENTS_ROOT
|
||||||
|
from app.agents.new_chat.state_reducers import _CLEAR
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
async def cloud_rm(
|
||||||
|
mw: "SurfSenseFilesystemMiddleware",
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
validated: str,
|
||||||
|
) -> Command | str:
|
||||||
|
"""Stage a deletion in cloud mode (commit at end of turn)."""
|
||||||
|
if validated in ("/", DOCUMENTS_ROOT):
|
||||||
|
return f"Error: refusing to rm '{validated}'."
|
||||||
|
if not validated.startswith(DOCUMENTS_ROOT + "/"):
|
||||||
|
return (
|
||||||
|
"Error: cloud rm must target a path under /documents/ "
|
||||||
|
f"(got '{validated}')."
|
||||||
|
)
|
||||||
|
|
||||||
|
anon = runtime.state.get("kb_anon_doc") or {}
|
||||||
|
if isinstance(anon, dict) and str(anon.get("path") or "") == validated:
|
||||||
|
return "Error: the anonymous uploaded document is read-only."
|
||||||
|
|
||||||
|
staged_dirs = list(runtime.state.get("staged_dirs") or [])
|
||||||
|
if validated in staged_dirs:
|
||||||
|
return (
|
||||||
|
f"Error: '{validated}' is a directory. Use rmdir for "
|
||||||
|
"empty directories."
|
||||||
|
)
|
||||||
|
pending_dir_deletes = list(runtime.state.get("pending_dir_deletes") or [])
|
||||||
|
if any(
|
||||||
|
isinstance(d, dict) and d.get("path") == validated
|
||||||
|
for d in pending_dir_deletes
|
||||||
|
):
|
||||||
|
return f"Error: '{validated}' is already queued for rmdir."
|
||||||
|
|
||||||
|
backend = mw._get_backend(runtime)
|
||||||
|
if isinstance(backend, KBPostgresBackend):
|
||||||
|
children = await backend.als_info(validated)
|
||||||
|
if children:
|
||||||
|
return (
|
||||||
|
f"Error: '{validated}' is a directory. Use rmdir for "
|
||||||
|
"empty directories."
|
||||||
|
)
|
||||||
|
|
||||||
|
pending_deletes = list(runtime.state.get("pending_deletes") or [])
|
||||||
|
if any(
|
||||||
|
isinstance(d, dict) and d.get("path") == validated for d in pending_deletes
|
||||||
|
):
|
||||||
|
return f"'{validated}' is already queued for deletion."
|
||||||
|
|
||||||
|
files_state = runtime.state.get("files") or {}
|
||||||
|
doc_id_by_path = runtime.state.get("doc_id_by_path") or {}
|
||||||
|
resolved_doc_id: int | None = doc_id_by_path.get(validated)
|
||||||
|
if (
|
||||||
|
validated not in files_state
|
||||||
|
and resolved_doc_id is None
|
||||||
|
and isinstance(backend, KBPostgresBackend)
|
||||||
|
):
|
||||||
|
loaded = await backend._load_file_data(validated)
|
||||||
|
if loaded is None:
|
||||||
|
return f"Error: file '{validated}' not found."
|
||||||
|
_, resolved_doc_id = loaded
|
||||||
|
|
||||||
|
files_update: dict[str, Any] = {validated: None}
|
||||||
|
update: dict[str, Any] = {
|
||||||
|
"pending_deletes": [
|
||||||
|
{
|
||||||
|
"path": validated,
|
||||||
|
"tool_call_id": runtime.tool_call_id,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"files": files_update,
|
||||||
|
"doc_id_by_path": {validated: None},
|
||||||
|
"messages": [
|
||||||
|
ToolMessage(
|
||||||
|
content=(
|
||||||
|
f"Staged delete of '{validated}' (will commit at "
|
||||||
|
"end of turn)."
|
||||||
|
),
|
||||||
|
tool_call_id=runtime.tool_call_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
dirty_paths = list(runtime.state.get("dirty_paths") or [])
|
||||||
|
if validated in dirty_paths:
|
||||||
|
new_dirty: list[Any] = [_CLEAR]
|
||||||
|
for entry in dirty_paths:
|
||||||
|
if entry != validated:
|
||||||
|
new_dirty.append(entry)
|
||||||
|
update["dirty_paths"] = new_dirty
|
||||||
|
update["dirty_path_tool_calls"] = {validated: None}
|
||||||
|
|
||||||
|
return Command(update=update)
|
||||||
|
|
||||||
|
|
||||||
|
async def desktop_rm(
|
||||||
|
mw: "SurfSenseFilesystemMiddleware",
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
validated: str,
|
||||||
|
) -> Command | str:
|
||||||
|
"""Hit disk immediately in desktop mode."""
|
||||||
|
backend = mw._get_backend(runtime)
|
||||||
|
adelete = getattr(backend, "adelete_file", None)
|
||||||
|
if not callable(adelete):
|
||||||
|
return "Error: rm is not supported by the active backend."
|
||||||
|
res: WriteResult = await adelete(validated)
|
||||||
|
if res.error:
|
||||||
|
return res.error
|
||||||
|
return Command(
|
||||||
|
update={
|
||||||
|
"files": {validated: None},
|
||||||
|
"messages": [
|
||||||
|
ToolMessage(
|
||||||
|
content=f"Deleted file '{res.path or validated}'",
|
||||||
|
tool_call_id=runtime.tool_call_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""``rm`` factory: resolve + validate the path, then dispatch to cloud / desktop."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Annotated
|
||||||
|
|
||||||
|
from deepagents.backends.utils import validate_path
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
from langchain_core.tools import BaseTool, StructuredTool
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
|
||||||
|
from ...middleware.async_dispatch import run_async_blocking
|
||||||
|
from ...middleware.mode import is_cloud
|
||||||
|
from ...middleware.path_resolution import resolve_relative
|
||||||
|
from .description import select_description
|
||||||
|
from .helpers import cloud_rm, desktop_rm
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def create_rm_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
|
||||||
|
description = select_description(mw._filesystem_mode)
|
||||||
|
|
||||||
|
async def async_rm(
|
||||||
|
path: Annotated[
|
||||||
|
str,
|
||||||
|
"Absolute or relative path to the file to delete.",
|
||||||
|
],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> Command | str:
|
||||||
|
if not path or not path.strip():
|
||||||
|
return "Error: path is required."
|
||||||
|
|
||||||
|
target = resolve_relative(mw, path, runtime)
|
||||||
|
try:
|
||||||
|
validated = validate_path(target)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
if is_cloud(mw._filesystem_mode):
|
||||||
|
return await cloud_rm(mw, runtime, validated)
|
||||||
|
return await desktop_rm(mw, runtime, validated)
|
||||||
|
|
||||||
|
def sync_rm(
|
||||||
|
path: Annotated[
|
||||||
|
str,
|
||||||
|
"Absolute or relative path to the file to delete.",
|
||||||
|
],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> Command | str:
|
||||||
|
return run_async_blocking(async_rm(path, runtime))
|
||||||
|
|
||||||
|
return StructuredTool.from_function(
|
||||||
|
name="rm",
|
||||||
|
description=description,
|
||||||
|
func=sync_rm,
|
||||||
|
coroutine=async_rm,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Tool: ``rmdir`` — delete an empty directory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .index import create_rmdir_tool
|
||||||
|
|
||||||
|
__all__ = ["create_rmdir_tool"]
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""Mode-specific description strings for ``rmdir``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
|
||||||
|
_CLOUD_DESCRIPTION = """Deletes an empty directory under `/documents/`.
|
||||||
|
|
||||||
|
Mirrors POSIX `rmdir path`: refuses non-empty directories. Recursive
|
||||||
|
deletion (`rm -r`) is intentionally NOT supported — clear contents with
|
||||||
|
`rm` first.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- path: absolute or relative directory path. Cannot target the root,
|
||||||
|
`/documents`, the current cwd, or any ancestor of cwd (use `cd` to
|
||||||
|
move out first).
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Emptiness is evaluated against the post-staged view, so a same-turn
|
||||||
|
`rm /a/x.md` followed by `rmdir /a` is fine.
|
||||||
|
- If the directory was added in this same turn via `mkdir` and never
|
||||||
|
committed, the staged mkdir is dropped instead of issuing a delete.
|
||||||
|
- The action is reversible via the per-action revert flow when action
|
||||||
|
logging is enabled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_DESKTOP_DESCRIPTION = """Deletes an empty directory from disk.
|
||||||
|
|
||||||
|
Mirrors POSIX `rmdir path`: refuses non-empty directories. Recursive
|
||||||
|
deletion is NOT supported. The deletion hits disk immediately and is
|
||||||
|
NOT reversible via the agent's revert flow.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- path: absolute mount-prefixed directory path. Cannot target the mount
|
||||||
|
root or any directory containing files/subfolders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def select_description(mode: FilesystemMode) -> str:
|
||||||
|
if mode == FilesystemMode.CLOUD:
|
||||||
|
return _CLOUD_DESCRIPTION
|
||||||
|
return _DESKTOP_DESCRIPTION
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
"""Cloud and desktop ``rmdir`` branches.
|
||||||
|
|
||||||
|
Both branches receive an already-resolved + validated absolute path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import posixpath
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from deepagents.backends.protocol import WriteResult
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
from langchain_core.messages import ToolMessage
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
from app.agents.new_chat.middleware.kb_postgres_backend import KBPostgresBackend
|
||||||
|
from app.agents.new_chat.path_resolver import DOCUMENTS_ROOT
|
||||||
|
from app.agents.new_chat.state_reducers import _CLEAR
|
||||||
|
|
||||||
|
from ...middleware.path_resolution import current_cwd
|
||||||
|
from ...shared.paths import is_ancestor_of
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
async def cloud_rmdir(
|
||||||
|
mw: "SurfSenseFilesystemMiddleware",
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
validated: str,
|
||||||
|
) -> Command | str:
|
||||||
|
"""Stage an empty-folder delete in cloud mode (commit at end of turn)."""
|
||||||
|
if validated in ("/", DOCUMENTS_ROOT):
|
||||||
|
return f"Error: refusing to rmdir '{validated}'."
|
||||||
|
if not validated.startswith(DOCUMENTS_ROOT + "/"):
|
||||||
|
return (
|
||||||
|
"Error: cloud rmdir must target a path under /documents/ "
|
||||||
|
f"(got '{validated}')."
|
||||||
|
)
|
||||||
|
|
||||||
|
cwd = current_cwd(mw, runtime)
|
||||||
|
if validated == cwd or is_ancestor_of(validated, cwd):
|
||||||
|
return (
|
||||||
|
f"Error: cannot rmdir '{validated}' because the current "
|
||||||
|
"cwd is at or under it. cd out first."
|
||||||
|
)
|
||||||
|
|
||||||
|
staged_dirs = list(runtime.state.get("staged_dirs") or [])
|
||||||
|
pending_dir_deletes = list(runtime.state.get("pending_dir_deletes") or [])
|
||||||
|
if any(
|
||||||
|
isinstance(d, dict) and d.get("path") == validated
|
||||||
|
for d in pending_dir_deletes
|
||||||
|
):
|
||||||
|
return f"'{validated}' is already queued for deletion."
|
||||||
|
|
||||||
|
backend = mw._get_backend(runtime)
|
||||||
|
|
||||||
|
exists_in_staged = validated in staged_dirs
|
||||||
|
children: list = []
|
||||||
|
if isinstance(backend, KBPostgresBackend):
|
||||||
|
children = list(await backend.als_info(validated))
|
||||||
|
|
||||||
|
if (
|
||||||
|
isinstance(backend, KBPostgresBackend)
|
||||||
|
and not children
|
||||||
|
and not exists_in_staged
|
||||||
|
):
|
||||||
|
loaded = await backend._load_file_data(validated)
|
||||||
|
if loaded is not None:
|
||||||
|
return f"Error: '{validated}' is a file. Use rm to delete files."
|
||||||
|
parent = posixpath.dirname(validated) or "/"
|
||||||
|
parent_listing = await backend.als_info(parent)
|
||||||
|
parent_has_dir = any(
|
||||||
|
info.get("path") == validated and info.get("is_dir")
|
||||||
|
for info in parent_listing
|
||||||
|
)
|
||||||
|
if not parent_has_dir:
|
||||||
|
return f"Error: directory '{validated}' not found."
|
||||||
|
|
||||||
|
if children:
|
||||||
|
return (
|
||||||
|
f"Error: directory '{validated}' is not empty. Remove contents first."
|
||||||
|
)
|
||||||
|
|
||||||
|
if exists_in_staged:
|
||||||
|
rest = [d for d in staged_dirs if d != validated]
|
||||||
|
return Command(
|
||||||
|
update={
|
||||||
|
"staged_dirs": [_CLEAR, *rest],
|
||||||
|
"staged_dir_tool_calls": {validated: None},
|
||||||
|
"messages": [
|
||||||
|
ToolMessage(
|
||||||
|
content=(f"Un-staged directory '{validated}'."),
|
||||||
|
tool_call_id=runtime.tool_call_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Command(
|
||||||
|
update={
|
||||||
|
"pending_dir_deletes": [
|
||||||
|
{
|
||||||
|
"path": validated,
|
||||||
|
"tool_call_id": runtime.tool_call_id,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"messages": [
|
||||||
|
ToolMessage(
|
||||||
|
content=(
|
||||||
|
f"Staged rmdir of '{validated}' (will commit "
|
||||||
|
"at end of turn)."
|
||||||
|
),
|
||||||
|
tool_call_id=runtime.tool_call_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def desktop_rmdir(
|
||||||
|
mw: "SurfSenseFilesystemMiddleware",
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
validated: str,
|
||||||
|
) -> Command | str:
|
||||||
|
"""Hit disk immediately in desktop mode."""
|
||||||
|
backend = mw._get_backend(runtime)
|
||||||
|
armdir = getattr(backend, "armdir", None)
|
||||||
|
if not callable(armdir):
|
||||||
|
return "Error: rmdir is not supported by the active backend."
|
||||||
|
res: WriteResult = await armdir(validated)
|
||||||
|
if res.error:
|
||||||
|
return res.error
|
||||||
|
return Command(
|
||||||
|
update={
|
||||||
|
"messages": [
|
||||||
|
ToolMessage(
|
||||||
|
content=f"Deleted directory '{res.path or validated}'",
|
||||||
|
tool_call_id=runtime.tool_call_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""``rmdir`` factory: resolve + validate the path, then dispatch to cloud / desktop."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Annotated
|
||||||
|
|
||||||
|
from deepagents.backends.utils import validate_path
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
from langchain_core.tools import BaseTool, StructuredTool
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
|
||||||
|
from ...middleware.async_dispatch import run_async_blocking
|
||||||
|
from ...middleware.mode import is_cloud
|
||||||
|
from ...middleware.path_resolution import resolve_relative
|
||||||
|
from .description import select_description
|
||||||
|
from .helpers import cloud_rmdir, desktop_rmdir
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def create_rmdir_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
|
||||||
|
description = select_description(mw._filesystem_mode)
|
||||||
|
|
||||||
|
async def async_rmdir(
|
||||||
|
path: Annotated[
|
||||||
|
str,
|
||||||
|
"Absolute or relative path of the empty directory to delete.",
|
||||||
|
],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> Command | str:
|
||||||
|
if not path or not path.strip():
|
||||||
|
return "Error: path is required."
|
||||||
|
|
||||||
|
target = resolve_relative(mw, path, runtime)
|
||||||
|
try:
|
||||||
|
validated = validate_path(target)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
if is_cloud(mw._filesystem_mode):
|
||||||
|
return await cloud_rmdir(mw, runtime, validated)
|
||||||
|
return await desktop_rmdir(mw, runtime, validated)
|
||||||
|
|
||||||
|
def sync_rmdir(
|
||||||
|
path: Annotated[
|
||||||
|
str,
|
||||||
|
"Absolute or relative path of the empty directory to delete.",
|
||||||
|
],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> Command | str:
|
||||||
|
return run_async_blocking(async_rmdir(path, runtime))
|
||||||
|
|
||||||
|
return StructuredTool.from_function(
|
||||||
|
name="rmdir",
|
||||||
|
description=description,
|
||||||
|
func=sync_rmdir,
|
||||||
|
coroutine=async_rmdir,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""Tool: ``write_file`` — create or overwrite a text file."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .index import create_write_file_tool
|
||||||
|
|
||||||
|
__all__ = ["create_write_file_tool"]
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Mode-specific description strings for ``write_file``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_selection import FilesystemMode
|
||||||
|
|
||||||
|
_CLOUD_DESCRIPTION = """Writes a new text file to the workspace.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
- Files written under `/documents/<...>` are persisted as Documents at end
|
||||||
|
of turn.
|
||||||
|
- Use a `temp_` filename prefix (e.g. `temp_plan.md` or `/documents/temp_x.md`)
|
||||||
|
for scratch/working files; they are automatically discarded at end of turn.
|
||||||
|
- Writes outside `/documents/` are rejected unless the basename starts with
|
||||||
|
`temp_`.
|
||||||
|
- Supported outputs include common LLM-friendly text formats like markdown,
|
||||||
|
json, yaml, csv, xml, html, css, sql, and code files.
|
||||||
|
- Avoid placeholders; produce concrete and useful text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_DESKTOP_DESCRIPTION = """Writes a text file to disk.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
- Use mount-prefixed absolute paths like `/<mount>/sub/file.ext`.
|
||||||
|
- Writes hit disk immediately. There is no end-of-turn staging.
|
||||||
|
- Supported outputs include common LLM-friendly text formats like markdown,
|
||||||
|
json, yaml, csv, xml, html, css, sql, and code files.
|
||||||
|
- Avoid placeholders; produce concrete and useful text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def select_description(mode: FilesystemMode) -> str:
|
||||||
|
if mode == FilesystemMode.CLOUD:
|
||||||
|
return _CLOUD_DESCRIPTION
|
||||||
|
return _DESKTOP_DESCRIPTION
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""``write_file`` factory: resolve target, enforce cloud namespace, dispatch to backend."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Annotated, Any
|
||||||
|
|
||||||
|
from deepagents.backends.protocol import WriteResult
|
||||||
|
from deepagents.backends.utils import create_file_data, validate_path
|
||||||
|
from langchain.tools import ToolRuntime
|
||||||
|
from langchain_core.messages import ToolMessage
|
||||||
|
from langchain_core.tools import BaseTool, StructuredTool
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
|
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
|
||||||
|
|
||||||
|
from ...middleware.async_dispatch import run_async_blocking
|
||||||
|
from ...middleware.mode import is_cloud
|
||||||
|
from ...middleware.namespace_policy import check_cloud_write_namespace
|
||||||
|
from ...middleware.path_resolution import resolve_write_target_path
|
||||||
|
from .description import select_description
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ...middleware import SurfSenseFilesystemMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def create_write_file_tool(mw: "SurfSenseFilesystemMiddleware") -> BaseTool:
|
||||||
|
description = select_description(mw._filesystem_mode)
|
||||||
|
|
||||||
|
async def async_write_file(
|
||||||
|
file_path: Annotated[
|
||||||
|
str,
|
||||||
|
"Absolute path where the file should be created. Relative paths resolve against the current cwd.",
|
||||||
|
],
|
||||||
|
content: Annotated[str, "Text content to write to the file."],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> Command | str:
|
||||||
|
target = resolve_write_target_path(mw, file_path, runtime)
|
||||||
|
try:
|
||||||
|
validated = validate_path(target)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
namespace_error = check_cloud_write_namespace(mw, validated, runtime)
|
||||||
|
if namespace_error:
|
||||||
|
return namespace_error
|
||||||
|
|
||||||
|
backend = mw._get_backend(runtime)
|
||||||
|
res: WriteResult = await backend.awrite(validated, content)
|
||||||
|
if res.error:
|
||||||
|
return res.error
|
||||||
|
|
||||||
|
path = res.path or validated
|
||||||
|
files_update = res.files_update or {path: create_file_data(content)}
|
||||||
|
update: dict[str, Any] = {
|
||||||
|
"files": files_update,
|
||||||
|
"messages": [
|
||||||
|
ToolMessage(
|
||||||
|
content=f"Updated file {path}",
|
||||||
|
tool_call_id=runtime.tool_call_id,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if is_cloud(mw._filesystem_mode):
|
||||||
|
update["dirty_paths"] = [path]
|
||||||
|
update["dirty_path_tool_calls"] = {path: runtime.tool_call_id}
|
||||||
|
return Command(update=update)
|
||||||
|
|
||||||
|
def sync_write_file(
|
||||||
|
file_path: Annotated[
|
||||||
|
str,
|
||||||
|
"Absolute path where the file should be created. Relative paths resolve against the current cwd.",
|
||||||
|
],
|
||||||
|
content: Annotated[str, "Text content to write to the file."],
|
||||||
|
runtime: ToolRuntime[None, SurfSenseFilesystemState],
|
||||||
|
) -> Command | str:
|
||||||
|
return run_async_blocking(
|
||||||
|
async_write_file(file_path, content, runtime)
|
||||||
|
)
|
||||||
|
|
||||||
|
return StructuredTool.from_function(
|
||||||
|
name="write_file",
|
||||||
|
description=description,
|
||||||
|
func=sync_write_file,
|
||||||
|
coroutine=async_write_file,
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue