Skip to content

Commit 238fb4f

Browse files
authored
Merge pull request #5268 from Agenta-AI/feat/agent-mounts-v2
feat(platform): add durable files shared across agent sessions
2 parents f2b56dd + 51140f8 commit 238fb4f

29 files changed

Lines changed: 1836 additions & 70 deletions

File tree

api/oss/src/apis/fastapi/mounts/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ class MountQueryRequest(BaseModel):
3131
windowing: Optional[Windowing] = None
3232

3333

34+
class AgentMountQueryRequest(BaseModel):
35+
artifact_id: str
36+
name: str = "default"
37+
38+
3439
# ---------------------------------------------------------------------------
3540
# Response models
3641
# ---------------------------------------------------------------------------

api/oss/src/apis/fastapi/mounts/router.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from oss.src.core.mounts.service import MountsService
1414
from oss.src.core.mounts.types import (
15+
MountArtifactIdInvalid,
1516
MountDataInvalid,
1617
MountFileNotFound,
1718
MountImmutableField,
@@ -24,6 +25,7 @@
2425
)
2526

2627
from oss.src.apis.fastapi.mounts.models import (
28+
AgentMountQueryRequest,
2729
MountCreateRequest,
2830
MountCredentialsResponse,
2931
MountEditRequest,
@@ -65,6 +67,11 @@ async def wrapper(*args, **kwargs):
6567
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
6668
detail=e.message,
6769
) from e
70+
except MountArtifactIdInvalid as e:
71+
raise HTTPException(
72+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
73+
detail=e.message,
74+
) from e
6875
except MountSlugConflict as e:
6976
raise HTTPException(
7077
status_code=status.HTTP_409_CONFLICT,
@@ -130,6 +137,25 @@ def __init__(
130137
response_model_exclude_none=True,
131138
status_code=status.HTTP_200_OK,
132139
)
140+
# Fixed agent sub-paths must be registered before "/{mount_id}" so they win.
141+
self.router.add_api_route(
142+
"/agents/sign",
143+
self.sign_agent_mount_credentials,
144+
methods=["POST"],
145+
operation_id="sign_agent_mount_credentials",
146+
response_model=MountCredentialsResponse,
147+
response_model_exclude_none=True,
148+
status_code=status.HTTP_200_OK,
149+
)
150+
self.router.add_api_route(
151+
"/agents/query",
152+
self.query_agent_mount,
153+
methods=["POST"],
154+
operation_id="query_agent_mount",
155+
response_model=MountsResponse,
156+
response_model_exclude_none=True,
157+
status_code=status.HTTP_200_OK,
158+
)
133159
self.router.add_api_route(
134160
"/{mount_id}",
135161
self.fetch_mount,
@@ -284,6 +310,48 @@ async def query_mounts(
284310

285311
return MountsResponse(count=len(mounts), mounts=mounts)
286312

313+
@intercept_exceptions()
314+
@handle_mount_exceptions()
315+
async def sign_agent_mount_credentials(
316+
self,
317+
request: Request,
318+
*,
319+
artifact_id: str = Query(...),
320+
name: str = Query(default="default"),
321+
) -> MountCredentialsResponse:
322+
await self._check(request, Permission.RUN_SESSIONS)
323+
324+
mount = await self.mounts_service.get_or_create_agent_mount(
325+
project_id=UUID(request.state.project_id),
326+
user_id=UUID(str(request.state.user_id)),
327+
artifact_id=artifact_id,
328+
name=name,
329+
)
330+
credentials = await sign_mount_credentials(
331+
mounts_service=self.mounts_service,
332+
project_id=UUID(request.state.project_id),
333+
mount_id=mount.id,
334+
)
335+
return MountCredentialsResponse(count=1, mount=mount, credentials=credentials)
336+
337+
@intercept_exceptions()
338+
@handle_mount_exceptions()
339+
async def query_agent_mount(
340+
self,
341+
request: Request,
342+
*,
343+
body: AgentMountQueryRequest,
344+
) -> MountsResponse:
345+
await self._check(request, Permission.VIEW_SESSIONS)
346+
347+
mount = await self.mounts_service.fetch_agent_mount(
348+
project_id=UUID(request.state.project_id),
349+
artifact_id=body.artifact_id,
350+
name=body.name,
351+
)
352+
mounts = [mount] if mount else []
353+
return MountsResponse(count=len(mounts), mounts=mounts)
354+
287355
@intercept_exceptions()
288356
async def fetch_mount(
289357
self,

api/oss/src/core/mounts/interfaces.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ async def fetch_mount(
3636
mount_id: UUID,
3737
) -> Optional[Mount]: ...
3838

39+
@abstractmethod
40+
async def fetch_mount_by_slug(
41+
self,
42+
*,
43+
project_id: UUID,
44+
#
45+
slug: str,
46+
) -> Optional[Mount]: ...
47+
3948
@abstractmethod
4049
async def edit_mount(
4150
self,

api/oss/src/core/mounts/service.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from oss.src.core.mounts.interfaces import MountsDAOInterface
1919
from oss.src.core.store.storage import ObjectStore
2020
from oss.src.core.mounts.types import (
21+
MountArtifactIdInvalid,
2122
MountFileNotFound,
2223
MountNameInvalid,
2324
MountNotFound,
@@ -73,6 +74,21 @@ def mint_session_slug(*, session_id: str, name: str) -> str:
7374
return f"{_RESERVED_SLUG_PREFIX}{uuid5(_MOUNTS_NAMESPACE, session_id)}__{slugify_mount_name(name)}"
7475

7576

77+
def mint_agent_slug(*, artifact_id: str, name: str) -> str:
78+
"""Mint the deterministic reserved slug for an artifact mount.
79+
80+
Artifact IDs are UUID-parsed and rendered lowercase. Sign and query must use
81+
this same derivation byte-identically so they address the same mount.
82+
"""
83+
try:
84+
canonical_artifact_id = UUID(str(artifact_id))
85+
except (ValueError, TypeError, AttributeError) as e:
86+
raise MountArtifactIdInvalid(str(artifact_id)) from e
87+
88+
slug_name = slugify_mount_name(name)
89+
return f"{_RESERVED_SLUG_PREFIX}agent__{canonical_artifact_id}__{slug_name}"
90+
91+
7692
def reject_reserved_slug(slug: str) -> None:
7793
"""A caller may not author a slug in the reserved namespace (the service mints those)."""
7894
if slug.startswith(_RESERVED_SLUG_PREFIX):
@@ -188,6 +204,26 @@ async def get_or_create_session_mount(
188204
mount_create=mount_create,
189205
)
190206

207+
async def get_or_create_agent_mount(
208+
self,
209+
*,
210+
project_id: UUID,
211+
user_id: UUID,
212+
artifact_id: str,
213+
name: str = "default",
214+
) -> Mount:
215+
"""Bind idempotently one durable mount for an artifact, keyed by name."""
216+
slug_name = slugify_mount_name(name)
217+
mount_create = MountCreate(
218+
slug=mint_agent_slug(artifact_id=artifact_id, name=name),
219+
name=slug_name,
220+
)
221+
return await self.mounts_dao.upsert_mount(
222+
project_id=project_id,
223+
user_id=user_id,
224+
mount_create=mount_create,
225+
)
226+
191227
async def get_or_create_session_cwd(
192228
self,
193229
*,
@@ -216,6 +252,20 @@ async def fetch_mount(
216252
mount_id=mount_id,
217253
)
218254

255+
async def fetch_agent_mount(
256+
self,
257+
*,
258+
project_id: UUID,
259+
artifact_id: str,
260+
name: str = "default",
261+
) -> Optional[Mount]:
262+
"""Fetch the active artifact mount keyed by name without creating it."""
263+
slug = mint_agent_slug(artifact_id=artifact_id, name=name)
264+
return await self.mounts_dao.fetch_mount_by_slug(
265+
project_id=project_id,
266+
slug=slug,
267+
)
268+
219269
async def edit_mount(
220270
self,
221271
*,

api/oss/src/core/mounts/types.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ def __init__(self, name: str = "name"):
3333
self.name = name
3434

3535

36+
class MountArtifactIdInvalid(MountError):
37+
def __init__(self, artifact_id: str = "artifact_id"):
38+
super().__init__(f"Artifact id '{artifact_id}' must be a valid UUID.")
39+
self.artifact_id = artifact_id
40+
41+
3642
class MountImmutableField(MountError):
3743
def __init__(self, field: str = "field"):
3844
super().__init__(f"Mount field '{field}' is immutable after creation.")

api/oss/src/dbs/postgres/mounts/dao.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,29 @@ async def fetch_mount(
115115

116116
return map_mount_dbe_to_dto(mount_dbe=mount_dbe)
117117

118+
async def fetch_mount_by_slug(
119+
self,
120+
*,
121+
project_id: UUID,
122+
#
123+
slug: str,
124+
) -> Optional[Mount]:
125+
"""Fetch by slug, excluding archived rows for agent-mount reads."""
126+
async with self.engine.session() as session:
127+
stmt = select(MountDBE).where(
128+
MountDBE.project_id == project_id,
129+
MountDBE.slug == slug,
130+
MountDBE.deleted_at.is_(None),
131+
)
132+
133+
result = await session.execute(stmt)
134+
mount_dbe = result.scalar_one_or_none()
135+
136+
if not mount_dbe:
137+
return None
138+
139+
return map_mount_dbe_to_dto(mount_dbe=mount_dbe)
140+
118141
async def edit_mount(
119142
self,
120143
*,
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Acceptance tests for artifact-scoped agent mounts.
2+
3+
Query-only tests run without an object store. Credential-signing tests skip when
4+
the running API has no mount storage backend configured.
5+
"""
6+
7+
from uuid import uuid4
8+
9+
import pytest
10+
11+
12+
def _query_agent_mount(authed_api, artifact_id, *, name="default"):
13+
return authed_api(
14+
"POST",
15+
"/mounts/agents/query",
16+
json={"artifact_id": artifact_id, "name": name},
17+
)
18+
19+
20+
def _sign_agent_mount(authed_api, artifact_id, *, name="default"):
21+
response = authed_api(
22+
"POST",
23+
"/mounts/agents/sign",
24+
params={"artifact_id": artifact_id, "name": name},
25+
)
26+
if response.status_code == 503:
27+
pytest.skip("Mount storage backend not configured in this environment")
28+
return response
29+
30+
31+
class TestAgentMountReads:
32+
def test_query_rejects_non_uuid_artifact_id(self, authed_api):
33+
response = _query_agent_mount(authed_api, "not-a-uuid")
34+
assert response.status_code == 422, response.text
35+
36+
def test_query_unknown_uuid_stays_empty(self, authed_api):
37+
artifact_id = str(uuid4())
38+
39+
first = _query_agent_mount(authed_api, artifact_id)
40+
assert first.status_code == 200, first.text
41+
assert first.json()["count"] == 0
42+
assert first.json()["mounts"] == []
43+
44+
second = _query_agent_mount(authed_api, artifact_id)
45+
assert second.status_code == 200, second.text
46+
assert second.json()["count"] == 0
47+
assert second.json()["mounts"] == []
48+
49+
50+
class TestAgentMountSign:
51+
def test_sign_then_query_returns_same_mount(self, authed_api):
52+
artifact_id = str(uuid4())
53+
signed = _sign_agent_mount(authed_api, artifact_id)
54+
assert signed.status_code == 200, signed.text
55+
mount_id = signed.json()["mount"]["id"]
56+
57+
queried = _query_agent_mount(authed_api, artifact_id)
58+
assert queried.status_code == 200, queried.text
59+
assert queried.json()["count"] == 1
60+
assert queried.json()["mounts"][0]["id"] == mount_id
61+
62+
def test_sign_twice_returns_same_mount(self, authed_api):
63+
artifact_id = str(uuid4())
64+
first = _sign_agent_mount(authed_api, artifact_id)
65+
assert first.status_code == 200, first.text
66+
67+
second = _sign_agent_mount(authed_api, artifact_id)
68+
assert second.status_code == 200, second.text
69+
assert second.json()["mount"]["id"] == first.json()["mount"]["id"]

0 commit comments

Comments
 (0)