diff --git a/libs/deepagents/backends/filesystem.py b/libs/deepagents/backends/filesystem.py index 5b074920..f927f2cf 100644 --- a/libs/deepagents/backends/filesystem.py +++ b/libs/deepagents/backends/filesystem.py @@ -95,8 +95,8 @@ def ls_info(self, path: str) -> list[FileInfo]: results: list[FileInfo] = [] - # Convert cwd to string for comparison - cwd_str = str(self.cwd) + # Convert cwd to string for comparison (normalize to forward slashes) + cwd_str = str(self.cwd).replace("\\", "/") if not cwd_str.endswith("/"): cwd_str += "/" @@ -110,6 +110,8 @@ def ls_info(self, path: str) -> list[FileInfo]: continue abs_path = str(child_path) + # Normalize to POSIX style (forward slashes only) for consistency + abs_path = abs_path.replace("\\", "/") if not self.virtual_mode: # Non-virtual mode: use absolute paths @@ -140,16 +142,18 @@ def ls_info(self, path: str) -> list[FileInfo]: except OSError: results.append({"path": abs_path + "/", "is_dir": True}) else: - # Virtual mode: strip cwd prefix + # Virtual mode: strip cwd prefix and normalize to POSIX if abs_path.startswith(cwd_str): relative_path = abs_path[len(cwd_str) :] - elif abs_path.startswith(str(self.cwd)): + elif abs_path.startswith(str(self.cwd).replace("\\", "/")): # Handle case where cwd doesn't end with / - relative_path = abs_path[len(str(self.cwd)) :].lstrip("/") + relative_path = abs_path[len(str(self.cwd).replace("\\", "/")) :].lstrip("/").lstrip("\\") else: # Path is outside cwd, return as-is or skip relative_path = abs_path + # Normalize to POSIX style (forward slashes only) + relative_path = relative_path.replace("\\", "/") virt_path = "/" + relative_path if is_file: @@ -372,7 +376,10 @@ def _ripgrep_search(self, pattern: str, base_full: Path, include_glob: str | Non p = Path(ftext) if self.virtual_mode: try: - virt = "/" + str(p.resolve().relative_to(self.cwd)) + rel_path = str(p.resolve().relative_to(self.cwd)) + # Normalize to POSIX style (forward slashes only) + rel_path = rel_path.replace("\\", "/") + virt = "/" + rel_path except Exception: continue else: @@ -412,7 +419,10 @@ def _python_search(self, pattern: str, base_full: Path, include_glob: str | None if regex.search(line): if self.virtual_mode: try: - virt_path = "/" + str(fp.resolve().relative_to(self.cwd)) + rel_path = str(fp.resolve().relative_to(self.cwd)) + # Normalize to POSIX style (forward slashes only) + rel_path = rel_path.replace("\\", "/") + virt_path = "/" + rel_path except Exception: continue else: @@ -440,6 +450,9 @@ def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]: if not is_file: continue abs_path = str(matched_path) + # Normalize to POSIX style (forward slashes only) + abs_path = abs_path.replace("\\", "/") + if not self.virtual_mode: try: st = matched_path.stat() @@ -454,15 +467,17 @@ def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]: except OSError: results.append({"path": abs_path, "is_dir": False}) else: - cwd_str = str(self.cwd) + cwd_str = str(self.cwd).replace("\\", "/") if not cwd_str.endswith("/"): cwd_str += "/" if abs_path.startswith(cwd_str): relative_path = abs_path[len(cwd_str) :] - elif abs_path.startswith(str(self.cwd)): - relative_path = abs_path[len(str(self.cwd)) :].lstrip("/") + elif abs_path.startswith(str(self.cwd).replace("\\", "/")): + relative_path = abs_path[len(str(self.cwd).replace("\\", "/")) :].lstrip("/").lstrip("\\") else: relative_path = abs_path + # Normalize to POSIX style (forward slashes only) + relative_path = relative_path.replace("\\", "/") virt = "/" + relative_path try: st = matched_path.stat() diff --git a/libs/deepagents/middleware/filesystem.py b/libs/deepagents/middleware/filesystem.py index 89f843c1..17316ac9 100644 --- a/libs/deepagents/middleware/filesystem.py +++ b/libs/deepagents/middleware/filesystem.py @@ -119,7 +119,10 @@ def _validate_path(path: str, *, allowed_prefixes: Sequence[str] | None = None) normalized = os.path.normpath(path) normalized = normalized.replace("\\", "/") - if not normalized.startswith("/"): + # Only add leading slash if it's not a Windows absolute path (with drive letter or UNC prefix) + # Windows absolute paths like "C:/Users/..." will be kept as-is + drive, _ = os.path.splitdrive(normalized) + if not normalized.startswith("/") and not drive: normalized = f"/{normalized}" if allowed_prefixes is not None and not any(normalized.startswith(prefix) for prefix in allowed_prefixes): diff --git a/libs/deepagents/tests/unit_tests/backends/test_filesystem_backend.py b/libs/deepagents/tests/unit_tests/backends/test_filesystem_backend.py index ef0c8181..32f74a58 100644 --- a/libs/deepagents/tests/unit_tests/backends/test_filesystem_backend.py +++ b/libs/deepagents/tests/unit_tests/backends/test_filesystem_backend.py @@ -21,9 +21,10 @@ def test_filesystem_backend_normal_mode(tmp_path: Path): # ls_info absolute path - should only list files in root, not subdirectories infos = be.ls_info(str(root)) paths = {i["path"] for i in infos} - assert str(f1) in paths # File in root should be listed - assert str(f2) not in paths # File in subdirectory should NOT be listed - assert (str(root) + "/dir/") in paths # Directory should be listed + # Normalize paths for comparison (backend returns POSIX-style paths) + assert str(f1).replace("\\", "/") in paths # File in root should be listed + assert str(f2).replace("\\", "/") not in paths # File in subdirectory should NOT be listed + assert (str(root).replace("\\", "/") + "/dir/") in paths # Directory should be listed # read, edit, write txt = be.read(str(f1)) @@ -39,7 +40,7 @@ def test_filesystem_backend_normal_mode(tmp_path: Path): # glob_info g = be.glob_info("*.py", path=str(root)) - assert any(i["path"] == str(f2) for i in g) + assert any(i["path"] == str(f2).replace("\\", "/") for i in g) def test_filesystem_backend_virtual_mode(tmp_path: Path): @@ -148,15 +149,16 @@ def test_filesystem_backend_ls_normal_mode_nested(tmp_path: Path): root_listing = be.ls_info(str(root)) root_paths = [fi["path"] for fi in root_listing] - assert str(root / "file1.txt") in root_paths - assert str(root / "subdir") + "/" in root_paths - assert str(root / "subdir" / "file2.txt") not in root_paths + # Normalize paths for comparison (backend returns POSIX-style paths) + assert str(root / "file1.txt").replace("\\", "/") in root_paths + assert str(root / "subdir").replace("\\", "/") + "/" in root_paths + assert str(root / "subdir" / "file2.txt").replace("\\", "/") not in root_paths subdir_listing = be.ls_info(str(root / "subdir")) subdir_paths = [fi["path"] for fi in subdir_listing] - assert str(root / "subdir" / "file2.txt") in subdir_paths - assert str(root / "subdir" / "nested") + "/" in subdir_paths - assert str(root / "subdir" / "nested" / "file3.txt") not in subdir_paths + assert str(root / "subdir" / "file2.txt").replace("\\", "/") in subdir_paths + assert str(root / "subdir" / "nested").replace("\\", "/") + "/" in subdir_paths + assert str(root / "subdir" / "nested" / "file3.txt").replace("\\", "/") not in subdir_paths def test_filesystem_backend_ls_trailing_slash(tmp_path: Path):