Skip to content

Commit 36e9ec1

Browse files
committed
feat: enhance workspace backend and agent configuration
- Introduced new constants for skills and memories paths in `workspace_backend.py`. - Updated `build_agent` to utilize these constants for backend configuration, ensuring proper mounting of skills and memories. - Refactored path normalization functions to support new virtual paths for skills and memories. - Enhanced tests to validate the integration of skills and memories in the agent's backend setup. - Improved overall path handling to reject invalid host paths and ensure compliance with workspace rules.
1 parent ac200bc commit 36e9ec1

4 files changed

Lines changed: 199 additions & 24 deletions

File tree

agent/app/agent.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
SYSTEM_PROMPT,
1515
build_system_prompt,
1616
)
17-
from app.workspace_backend import make_workspace_backend_factory
17+
from app.workspace_backend import (
18+
MEMORY_FILE,
19+
SKILLS_PREFIX,
20+
make_workspace_backend_factory,
21+
)
1822

1923
logger = logging.getLogger(__name__)
2024

@@ -54,6 +58,7 @@ def build_agent(
5458
from deepagents import create_deep_agent
5559
from deepagents.backends import (
5660
CompositeBackend,
61+
FilesystemBackend,
5762
LocalShellBackend,
5863
StateBackend,
5964
)
@@ -77,10 +82,18 @@ def build_agent(
7782
"store": store,
7883
}
7984

80-
kwargs["skills"] = [str(settings.skills_dir)]
81-
kwargs["memory"] = [str(settings.memories_dir)]
85+
# DeepAgents skills/memory paths are backend-virtual, not host absolute paths.
86+
# Host dirs are mounted at /skills and /memories via CompositeBackend routes.
87+
kwargs["skills"] = [f"{SKILLS_PREFIX}/"]
88+
kwargs["memory"] = [MEMORY_FILE]
8289
kwargs["backend"] = make_workspace_backend_factory(
83-
validated, LocalShellBackend, StateBackend, CompositeBackend
90+
validated,
91+
LocalShellBackend,
92+
StateBackend,
93+
CompositeBackend,
94+
filesystem_cls=FilesystemBackend,
95+
skills_dir=settings.skills_dir,
96+
memories_dir=settings.memories_dir,
8497
)
8598

8699
with _HARNESS_LOCK:

agent/app/workspace_backend.py

Lines changed: 88 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@
44

55
import logging
66
import re
7+
from pathlib import Path
78
from typing import Any
89

910
logger = logging.getLogger(__name__)
1011

1112
WORKSPACE_PREFIX = "/workspace"
13+
SKILLS_PREFIX = "/skills"
14+
MEMORIES_PREFIX = "/memories"
15+
MEMORY_FILE = f"{MEMORIES_PREFIX}/AGENTS.md"
16+
17+
_SYSTEM_PREFIXES = (SKILLS_PREFIX, MEMORIES_PREFIX)
1218
_DRIVE_IN_WORKSPACE = re.compile(r"^/workspace([A-Za-z]:)")
1319
_WINDOWS_ABS = re.compile(r"^[A-Za-z]:[\\/]")
1420

@@ -23,21 +29,12 @@ def normalize_workspace_path(path: str | None) -> str:
2329
2430
Rejects Windows absolute paths, ``/workspaceD:...`` concatenations, and ``..``.
2531
"""
26-
if path is None or not str(path).strip():
27-
raise ValueError("Path is required; use /workspace/<relative-path>")
28-
29-
raw = str(path).strip().replace("\\", "/")
30-
if _WINDOWS_ABS.match(raw) or raw.startswith("//"):
31-
raise ValueError(
32-
"Host absolute paths are not allowed. Use /workspace/<relative-path>."
33-
)
32+
raw = _normalize_slashes(path)
3433
if _DRIVE_IN_WORKSPACE.match(raw):
3534
raise ValueError(
3635
"Invalid path: do not concatenate /workspace with a Windows drive path "
3736
"(e.g. /workspaceD:\\...). Use /workspace/<relative-path>."
3837
)
39-
if not raw.startswith("/"):
40-
raw = f"/{raw}"
4138

4239
if raw == "/workspace" or raw.startswith("/workspace/"):
4340
virtual = raw
@@ -46,12 +43,41 @@ def normalize_workspace_path(path: str | None) -> str:
4643
else:
4744
virtual = f"/workspace{raw}" if raw.startswith("/") else f"/workspace/{raw}"
4845

46+
return _finalize_virtual_path(virtual, required_root="workspace")
47+
48+
49+
def normalize_backend_path(path: str | None) -> str:
50+
"""Normalize paths for workspace tools and system mounts.
51+
52+
Allows ``/workspace``, ``/skills``, and ``/memories`` virtual trees.
53+
Rejects host absolute paths and ``..`` traversal.
54+
"""
55+
raw = _normalize_slashes(path)
56+
for prefix in _SYSTEM_PREFIXES:
57+
if raw == prefix or raw.startswith(f"{prefix}/"):
58+
return _finalize_virtual_path(raw, required_root=prefix.lstrip("/"))
59+
return normalize_workspace_path(path)
60+
61+
62+
def _normalize_slashes(path: str | None) -> str:
63+
if path is None or not str(path).strip():
64+
raise ValueError("Path is required; use /workspace/<relative-path>")
65+
raw = str(path).strip().replace("\\", "/")
66+
if _WINDOWS_ABS.match(raw) or raw.startswith("//"):
67+
raise ValueError(
68+
"Host absolute paths are not allowed. Use /workspace/<relative-path>."
69+
)
70+
if not raw.startswith("/"):
71+
raw = f"/{raw}"
72+
return raw
73+
74+
75+
def _finalize_virtual_path(virtual: str, *, required_root: str) -> str:
4976
parts = [p for p in virtual.split("/") if p]
5077
if ".." in parts:
5178
raise ValueError("Path traversal ('..') is not allowed.")
52-
if not parts or parts[0] != "workspace":
79+
if not parts or parts[0] != required_root:
5380
raise ValueError("Filesystem tools must use /workspace/<relative-path>.")
54-
5581
return "/" + "/".join(parts)
5682

5783

@@ -98,7 +124,7 @@ def __getattr__(self, name: str) -> Any:
98124
return getattr(self._inner, name)
99125

100126
def _guard(self, path: str | None) -> str:
101-
return normalize_workspace_path(path)
127+
return normalize_backend_path(path)
102128

103129
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> Any:
104130
return coerce_read_result(
@@ -125,6 +151,9 @@ def edit(
125151
def ls(self, path: str = "/workspace") -> Any:
126152
return self._inner.ls(self._guard(path))
127153

154+
async def als(self, path: str = "/workspace") -> Any:
155+
return await self._inner.als(self._guard(path))
156+
128157
def glob(self, pattern: str, path: str = "/workspace") -> Any:
129158
return self._inner.glob(pattern, self._guard(path))
130159

@@ -137,16 +166,53 @@ def grep(
137166
safe = self._guard(path) if path else "/workspace"
138167
return self._inner.grep(pattern, path=safe, glob=glob)
139168

169+
def download_files(self, paths: list[str]) -> Any:
170+
return self._inner.download_files([self._guard(p) for p in paths])
171+
172+
async def adownload_files(self, paths: list[str]) -> Any:
173+
return await self._inner.adownload_files([self._guard(p) for p in paths])
174+
175+
176+
def _mount_system_routes(
177+
routes: dict[str, Any],
178+
filesystem_cls: Any | None,
179+
skills_dir: Path | None,
180+
memories_dir: Path | None,
181+
) -> None:
182+
if filesystem_cls is None:
183+
return
184+
if skills_dir is not None:
185+
root = Path(skills_dir)
186+
root.mkdir(parents=True, exist_ok=True)
187+
routes[f"{SKILLS_PREFIX}/"] = filesystem_cls(
188+
root_dir=str(root),
189+
virtual_mode=True,
190+
)
191+
if memories_dir is not None:
192+
root = Path(memories_dir)
193+
root.mkdir(parents=True, exist_ok=True)
194+
routes[f"{MEMORIES_PREFIX}/"] = filesystem_cls(
195+
root_dir=str(root),
196+
virtual_mode=True,
197+
)
198+
140199

141200
def make_workspace_backend_factory(
142201
validated_root,
143202
local_shell_cls,
144203
state_cls,
145204
composite_cls,
205+
*,
206+
filesystem_cls=None,
207+
skills_dir: Path | None = None,
208+
memories_dir: Path | None = None,
146209
):
147-
"""Bind project dir under /workspace with LocalShellBackend + CompositeBackend."""
210+
"""Bind project dir under /workspace; mount global skills/memories separately."""
148211

149212
def factory(rt):
213+
routes: dict[str, Any] = {}
214+
_mount_system_routes(routes, filesystem_cls, skills_dir, memories_dir)
215+
150216
if validated_root is not None:
151217
shell = local_shell_cls(
152218
root_dir=str(validated_root),
@@ -155,21 +221,24 @@ def factory(rt):
155221
)
156222
# Route /workspace/* onto the same shell-backed root. default=shell
157223
# keeps execute() cwd at the project directory.
158-
composite = composite_cls(
159-
default=shell,
160-
routes={f"{WORKSPACE_PREFIX}/": shell},
161-
)
224+
routes[f"{WORKSPACE_PREFIX}/"] = shell
225+
composite = composite_cls(default=shell, routes=routes)
162226
return WorkspacePathBackend(composite)
163-
return composite_cls(default=state_cls(rt), routes={})
227+
228+
return composite_cls(default=state_cls(rt), routes=routes)
164229

165230
return factory
166231

167232

168233
__all__ = [
234+
"MEMORY_FILE",
235+
"MEMORIES_PREFIX",
236+
"SKILLS_PREFIX",
169237
"WORKSPACE_PREFIX",
170238
"WorkspacePathBackend",
171239
"coerce_file_data",
172240
"coerce_read_result",
173241
"make_workspace_backend_factory",
242+
"normalize_backend_path",
174243
"normalize_workspace_path",
175244
]

agent/tests/test_build_agent.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from app.agent import _apply_mode_harness_profile, _build_subagents, build_agent
77
from app.config import get_settings
88
from app.prompts import build_system_prompt
9-
from app.workspace_backend import WorkspacePathBackend
9+
from app.workspace_backend import MEMORY_FILE, SKILLS_PREFIX, WorkspacePathBackend
1010

1111

1212
def test_build_agent_mounts_workspace_for_valid_dir(tmp_path: Path):
@@ -22,12 +22,14 @@ def fake_create_deep_agent(**kwargs):
2222
with (
2323
patch("deepagents.create_deep_agent", side_effect=fake_create_deep_agent),
2424
patch("deepagents.backends.LocalShellBackend") as local_shell,
25+
patch("deepagents.backends.FilesystemBackend") as filesystem,
2526
patch("deepagents.backends.CompositeBackend") as composite,
2627
patch("deepagents.backends.StateBackend"),
2728
patch("app.agent._apply_mode_harness_profile") as apply_profile,
2829
):
2930
shell = MagicMock(name="local-shell")
3031
local_shell.return_value = shell
32+
filesystem.side_effect = lambda **kwargs: MagicMock(name="fs", kwargs=kwargs)
3133
composite.return_value = MagicMock(name="composite")
3234
agent = build_agent(
3335
session_id="s1",
@@ -47,6 +49,10 @@ def fake_create_deep_agent(**kwargs):
4749
composite.assert_called_once()
4850
routes = composite.call_args.kwargs.get("routes") or composite.call_args.args[1]
4951
assert "/workspace/" in routes
52+
assert f"{SKILLS_PREFIX}/" in routes
53+
assert "/memories/" in routes
54+
assert captured["skills"] == [f"{SKILLS_PREFIX}/"]
55+
assert captured["memory"] == [MEMORY_FILE]
5056
assert captured["subagents"] == []
5157
assert "/workspace" in captured["system_prompt"]
5258
assert str(tmp_path.resolve()) not in captured["system_prompt"]
@@ -63,6 +69,7 @@ def fake_create_deep_agent(**kwargs):
6369
with (
6470
patch("deepagents.create_deep_agent", side_effect=fake_create_deep_agent),
6571
patch("deepagents.backends.LocalShellBackend"),
72+
patch("deepagents.backends.FilesystemBackend"),
6673
patch("deepagents.backends.CompositeBackend"),
6774
patch("deepagents.backends.StateBackend"),
6875
patch("app.agent._apply_mode_harness_profile") as apply_profile,
@@ -92,6 +99,7 @@ def fake_create_deep_agent(**kwargs):
9299
with (
93100
patch("deepagents.create_deep_agent", side_effect=fake_create_deep_agent),
94101
patch("deepagents.backends.LocalShellBackend") as local_shell,
102+
patch("deepagents.backends.FilesystemBackend"),
95103
patch("deepagents.backends.CompositeBackend") as composite,
96104
patch("deepagents.backends.StateBackend") as state_backend,
97105
patch("app.agent._apply_mode_harness_profile"),
@@ -111,6 +119,9 @@ def fake_create_deep_agent(**kwargs):
111119
assert backend is composite.return_value
112120
local_shell.assert_not_called()
113121
state_backend.assert_called_once()
122+
routes = composite.call_args.kwargs.get("routes") or {}
123+
assert f"{SKILLS_PREFIX}/" in routes
124+
assert captured["skills"] == [f"{SKILLS_PREFIX}/"]
114125
assert captured["system_prompt"] == build_system_prompt(has_workspace=False)
115126

116127

@@ -126,6 +137,7 @@ def fake_create_deep_agent(**kwargs):
126137
with (
127138
patch("deepagents.create_deep_agent", side_effect=fake_create_deep_agent),
128139
patch("deepagents.backends.LocalShellBackend") as local_shell,
140+
patch("deepagents.backends.FilesystemBackend"),
129141
patch("deepagents.backends.CompositeBackend") as composite,
130142
patch("deepagents.backends.StateBackend") as state_backend,
131143
patch("app.agent._apply_mode_harness_profile"),
@@ -154,6 +166,7 @@ def fake_create_deep_agent(**kwargs):
154166

155167
with (
156168
patch("deepagents.create_deep_agent", side_effect=fake_create_deep_agent),
169+
patch("deepagents.backends.FilesystemBackend"),
157170
patch("deepagents.backends.CompositeBackend"),
158171
patch("deepagents.backends.StateBackend"),
159172
patch("app.agent._apply_mode_harness_profile"),

0 commit comments

Comments
 (0)