From 5e17af45301869cd262266990e62cdcf18200604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Fri, 3 Jul 2026 17:43:57 +0800 Subject: [PATCH] feat(api): add sandbox info endpoint for Agent App conversations (#38390) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../console/app/agent_app_sandbox.py | 41 +++++++++++++- api/openapi/markdown/console-openapi.md | 23 ++++++++ api/services/agent_app_sandbox_service.py | 41 ++++++++++++-- .../console/app/test_agent_app_sandbox.py | 16 +++++- .../test_agent_app_sandbox_service.py | 53 ++++++++++++------- .../generated/api/console/agent/orpc.gen.ts | 39 ++++++++++---- .../generated/api/console/agent/types.gen.ts | 23 ++++++++ .../generated/api/console/agent/zod.gen.ts | 21 ++++++++ 8 files changed, 223 insertions(+), 34 deletions(-) diff --git a/api/controllers/console/app/agent_app_sandbox.py b/api/controllers/console/app/agent_app_sandbox.py index 9d92c078bd4..50cf4ca6b04 100644 --- a/api/controllers/console/app/agent_app_sandbox.py +++ b/api/controllers/console/app/agent_app_sandbox.py @@ -44,6 +44,10 @@ class AgentSandboxListQuery(BaseModel): path: str = Field(default=".", description="Directory path relative to the sandbox workspace") +class AgentSandboxInfoQuery(BaseModel): + conversation_id: str = Field(min_length=1, description="Agent App conversation ID") + + class AgentSandboxFileQuery(BaseModel): conversation_id: str = Field(min_length=1, description="Agent App conversation ID") path: str = Field(min_length=1, description="File path relative to the sandbox workspace") @@ -91,6 +95,11 @@ class SandboxListResponse(ResponseModel): truncated: bool = False +class SandboxInfoResponse(ResponseModel): + session_id: str + workspace_cwd: str + + class SandboxReadResponse(ResponseModel): path: str size: int | None = None @@ -114,7 +123,13 @@ register_schema_models( AgentSandboxUploadPayload, WorkflowAgentSandboxUploadPayload, ) -register_response_schema_models(console_ns, SandboxListResponse, SandboxReadResponse, SandboxUploadResponse) +register_response_schema_models( + console_ns, + SandboxInfoResponse, + SandboxListResponse, + SandboxReadResponse, + SandboxUploadResponse, +) def _handle(exc: Exception) -> tuple[dict[str, object], int]: @@ -133,6 +148,30 @@ def _handle(exc: Exception) -> tuple[dict[str, object], int]: raise exc +@console_ns.route("/agent//sandbox") +class AgentAppSandboxInfoResource(Resource): + @console_ns.doc("get_agent_app_sandbox_info") + @console_ns.doc(description="Get basic information for an Agent App conversation sandbox") + @console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxInfoQuery)}) + @console_ns.response(200, "Sandbox information returned", console_ns.models[SandboxInfoResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + def get(self, tenant_id: str, agent_id: UUID): + app_model = resolve_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id) + query = query_params_from_request(AgentSandboxInfoQuery) + try: + result = AgentAppSandboxService().get_info( + tenant_id=tenant_id, + app_id=app_model.id, + conversation_id=query.conversation_id, + ) + except Exception as exc: + return _handle(exc) + return result.model_dump() + + @console_ns.route("/agent//sandbox/files") class AgentAppSandboxListResource(Resource): @console_ns.doc("list_agent_app_sandbox_files") diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 434315914aa..d261e338bb1 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -1217,6 +1217,22 @@ List workflow apps that reference this Agent App's bound Agent (read-only) | 200 | Referencing workflows listed successfully | **application/json**: [AgentReferencingWorkflowsResponse](#agentreferencingworkflowsresponse)
| | 404 | Agent not found | | +### [GET] /agent/{agent_id}/sandbox +Get basic information for an Agent App conversation sandbox + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | Agent ID | Yes | string (uuid) | +| conversation_id | query | Agent App conversation ID | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Sandbox information returned | **application/json**: [SandboxInfoResponse](#sandboxinforesponse)
| + ### [GET] /agent/{agent_id}/sandbox/files List a directory in an Agent App conversation sandbox @@ -20385,6 +20401,13 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs. | size | integer | | No | | type | string,
**Available values:** "dir", "file", "other", "symlink" | *Enum:* `"dir"`, `"file"`, `"other"`, `"symlink"` | Yes | +#### SandboxInfoResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| session_id | string | | Yes | +| workspace_cwd | string | | Yes | + #### SandboxListResponse | Name | Type | Description | Required | diff --git a/api/services/agent_app_sandbox_service.py b/api/services/agent_app_sandbox_service.py index 8836c02291c..be301f5cd14 100644 --- a/api/services/agent_app_sandbox_service.py +++ b/api/services/agent_app_sandbox_service.py @@ -13,7 +13,7 @@ from collections.abc import Callable from agenton.compositor import CompositorSessionSnapshot from dify_agent.client import Client from dify_agent.protocol import RuntimeLayerSpec, SandboxLocator, build_sandbox_locator_from_layer_specs -from pydantic import TypeAdapter +from pydantic import BaseModel, TypeAdapter from sqlalchemy import select from configs import dify_config @@ -38,8 +38,15 @@ class AgentSandboxInspectorError(Exception): self.status_code = status_code +class AgentSandboxInfo(BaseModel): + """Basic Agent App sandbox metadata returned after a successful availability probe.""" + + session_id: str + workspace_cwd: str + + class AgentAppSandboxService: - """List/read/upload files in an Agent App conversation sandbox.""" + """Inspect and proxy file access for an Agent App conversation sandbox.""" def __init__( self, @@ -50,6 +57,18 @@ class AgentAppSandboxService: self._session_store = session_store or AgentAppRuntimeSessionStore() self._client_factory = client_factory or _default_client_factory + def get_info(self, *, tenant_id: str, app_id: str, conversation_id: str) -> AgentSandboxInfo: + locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id) + session_id, workspace_cwd = _extract_shell_workspace_or_raise( + snapshot=locator.session_snapshot, + not_found_message="this conversation's agent has no sandbox workspace", + ) + + return AgentSandboxInfo( + session_id=session_id, + workspace_cwd=workspace_cwd, + ) + def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str): locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id) return self._client_factory().list_sandbox_files_sync(locator, path) @@ -205,6 +224,22 @@ def _build_locator_or_raise( raise AgentSandboxInspectorError("no_sandbox", not_found_message, status_code=404) from exc +def _extract_shell_workspace_or_raise( + *, + snapshot: CompositorSessionSnapshot, + not_found_message: str, +) -> tuple[str, str]: + shell_layer = next((layer for layer in snapshot.layers if layer.name == "shell"), None) + if shell_layer is None: + raise AgentSandboxInspectorError("no_sandbox", not_found_message, status_code=404) + + session_id = shell_layer.runtime_state.get("session_id") + workspace_cwd = shell_layer.runtime_state.get("workspace_cwd") + if not isinstance(session_id, str) or not isinstance(workspace_cwd, str): + raise AgentSandboxInspectorError("no_sandbox", not_found_message, status_code=404) + return session_id, workspace_cwd + + def _deserialize_runtime_layer_specs(value: str | None) -> list[RuntimeLayerSpec]: if not value: return [] @@ -222,4 +257,4 @@ def _default_client_factory() -> Client: return Client(base_url=base_url) -__all__ = ["AgentAppSandboxService", "AgentSandboxInspectorError", "WorkflowAgentSandboxService"] +__all__ = ["AgentAppSandboxService", "AgentSandboxInfo", "AgentSandboxInspectorError", "WorkflowAgentSandboxService"] diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py index 9b0c530f4fd..fdb534fc092 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_app_sandbox.py @@ -9,13 +9,17 @@ from dify_agent.protocol import SandboxListResponse, SandboxReadResponse, Sandbo from controllers.console import agent_app_sandbox as module from models.model import App, AppMode, IconType -from services.agent_app_sandbox_service import AgentSandboxInspectorError +from services.agent_app_sandbox_service import AgentSandboxInfo, AgentSandboxInspectorError class _AgentAppService: def __init__(self) -> None: self.calls: list[tuple[str, str, str, str, str]] = [] + def get_info(self, *, tenant_id: str, app_id: str, conversation_id: str) -> AgentSandboxInfo: + self.calls.append(("info", tenant_id, app_id, conversation_id, "")) + return AgentSandboxInfo(session_id="abc1234", workspace_cwd="~/workspace/abc1234") + def list_files(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> SandboxListResponse: self.calls.append(("list", tenant_id, app_id, conversation_id, path)) return SandboxListResponse(path=path, entries=[], truncated=False) @@ -131,14 +135,17 @@ def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPat SimpleNamespace(get_json=lambda silent=True: {"conversation_id": "conv-1", "path": "report.txt"}), ) + info = unwrap(module.AgentAppSandboxInfoResource.get)(object(), "tenant-1", "agent-1") listing = unwrap(module.AgentAppSandboxListResource.get)(object(), "tenant-1", "agent-1") preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), "tenant-1", "agent-1") upload = unwrap(module.AgentAppSandboxUploadResource.post)(object(), "tenant-1", "agent-1") + assert info == {"session_id": "abc1234", "workspace_cwd": "~/workspace/abc1234"} assert listing["path"] == "sub/report.txt" assert preview["text"] == "hello" assert upload["file"]["reference"] == "dify-file-ref:file-1" assert service.calls == [ + ("info", "tenant-1", "app-1", "conv-1", ""), ("list", "tenant-1", "app-1", "conv-1", "sub/report.txt"), ("read", "tenant-1", "app-1", "conv-1", "sub/report.txt"), ("upload", "tenant-1", "app-1", "conv-1", "report.txt"), @@ -147,6 +154,9 @@ def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPat def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytest.MonkeyPatch) -> None: class FailingService: + def get_info(self, **kwargs): + raise AgentSandboxInspectorError("no_active_session", "no active session", status_code=404) + def list_files(self, **kwargs): raise AgentSandboxInspectorError("no_active_session", "no active session", status_code=404) @@ -156,6 +166,10 @@ def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytes module, "query_params_from_request", lambda model: SimpleNamespace(conversation_id="conv-1", path=".") ) + assert unwrap(module.AgentAppSandboxInfoResource.get)(object(), "tenant-1", "agent-1") == ( + {"code": "no_active_session", "message": "no active session"}, + 404, + ) assert unwrap(module.AgentAppSandboxListResource.get)(object(), "tenant-1", "agent-1") == ( {"code": "no_active_session", "message": "no active session"}, 404, diff --git a/api/tests/unit_tests/services/test_agent_app_sandbox_service.py b/api/tests/unit_tests/services/test_agent_app_sandbox_service.py index c1ec5cd1ffe..978f8e8a24b 100644 --- a/api/tests/unit_tests/services/test_agent_app_sandbox_service.py +++ b/api/tests/unit_tests/services/test_agent_app_sandbox_service.py @@ -23,14 +23,23 @@ from services.agent_app_sandbox_service import ( ) -def _snapshot(*, session_id: str = "abc1234") -> CompositorSessionSnapshot: +def _snapshot( + *, + session_id: str = "abc1234", + shell_runtime_state: dict[str, str] | None = None, +) -> CompositorSessionSnapshot: + runtime_state = ( + {"session_id": session_id, "workspace_cwd": f"~/workspace/{session_id}"} + if shell_runtime_state is None + else shell_runtime_state + ) return CompositorSessionSnapshot( layers=[ LayerSessionSnapshot(name="execution_context", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), LayerSessionSnapshot( name="shell", lifecycle_state=LifecycleState.SUSPENDED, - runtime_state={"session_id": session_id, "workspace_cwd": f"~/workspace/{session_id}"}, + runtime_state=runtime_state, ), ] ) @@ -77,7 +86,10 @@ class FakeClient: ) -def _stored_session() -> StoredAgentAppSession: +def _stored_session( + *, + session_snapshot: CompositorSessionSnapshot | None = None, +) -> StoredAgentAppSession: return StoredAgentAppSession( scope=AgentAppSessionScope( tenant_id="tenant-1", @@ -86,12 +98,25 @@ def _stored_session() -> StoredAgentAppSession: agent_id="agent-1", agent_config_snapshot_id="snapshot-1", ), - session_snapshot=_snapshot(), + session_snapshot=_snapshot() if session_snapshot is None else session_snapshot, backend_run_id="run-1", runtime_layer_specs=_runtime_layer_specs(), ) +def test_agent_app_sandbox_service_get_info_returns_metadata() -> None: + store = FakeStore(_stored_session()) + client = FakeClient() + service = AgentAppSandboxService(session_store=store, client_factory=lambda: client) # type: ignore[arg-type] + + result = service.get_info(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") + + assert result.session_id == "abc1234" + assert result.workspace_cwd == "~/workspace/abc1234" + assert client.calls == [] + assert store.scope == ("tenant-1", "app-1", "conv-1") + + def test_agent_app_sandbox_service_builds_locator_and_proxies() -> None: store = FakeStore(_stored_session()) client = FakeClient() @@ -108,31 +133,21 @@ def test_agent_app_sandbox_service_raises_when_no_active_session() -> None: service = AgentAppSandboxService(session_store=FakeStore(None), client_factory=lambda: FakeClient()) # type: ignore[arg-type] with pytest.raises(AgentSandboxInspectorError) as exc_info: - service.read_file(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1", path="note.txt") + service.get_info(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") assert exc_info.value.code == "no_active_session" assert exc_info.value.status_code == 404 -def test_agent_app_sandbox_service_raises_when_runtime_specs_cannot_build_locator() -> None: - broken_session = StoredAgentAppSession( - scope=AgentAppSessionScope( - tenant_id="tenant-1", - app_id="app-1", - conversation_id="conv-1", - agent_id="agent-1", - agent_config_snapshot_id="snapshot-1", - ), - session_snapshot=_snapshot(), - backend_run_id="run-1", - runtime_layer_specs=[], - ) +def test_agent_app_sandbox_service_raises_when_shell_workspace_metadata_missing() -> None: + broken_session = _stored_session(session_snapshot=_snapshot(shell_runtime_state={"session_id": "abc1234"})) service = AgentAppSandboxService(session_store=FakeStore(broken_session), client_factory=lambda: FakeClient()) # type: ignore[arg-type] with pytest.raises(AgentSandboxInspectorError) as exc_info: - service.list_files(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1", path=".") + service.get_info(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1") assert exc_info.value.code == "no_sandbox" + assert exc_info.value.status_code == 404 def test_default_client_factory_requires_agent_backend_base_url(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts index c813a2f5bc6..e3671d8cf98 100644 --- a/packages/contracts/generated/api/console/agent/orpc.gen.ts +++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts @@ -98,6 +98,9 @@ import { zGetAgentByAgentIdSandboxFilesReadQuery, zGetAgentByAgentIdSandboxFilesReadResponse, zGetAgentByAgentIdSandboxFilesResponse, + zGetAgentByAgentIdSandboxPath, + zGetAgentByAgentIdSandboxQuery, + zGetAgentByAgentIdSandboxResponse, zGetAgentByAgentIdStatisticsSummaryPath, zGetAgentByAgentIdStatisticsSummaryQuery, zGetAgentByAgentIdStatisticsSummaryResponse, @@ -1224,7 +1227,23 @@ export const files5 = { upload: upload2, } +/** + * Get basic information for an Agent App conversation sandbox + */ +export const get31 = oc + .route({ + description: 'Get basic information for an Agent App conversation sandbox', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdSandbox', + path: '/agent/{agent_id}/sandbox', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdSandboxPath, query: zGetAgentByAgentIdSandboxQuery })) + .output(zGetAgentByAgentIdSandboxResponse) + export const sandbox = { + get: get31, files: files5, } @@ -1297,7 +1316,7 @@ export const skills3 = { bySlug, } -export const get31 = oc +export const get32 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1314,7 +1333,7 @@ export const get31 = oc .output(zGetAgentByAgentIdStatisticsSummaryResponse) export const summary = { - get: get31, + get: get32, } export const statistics = { @@ -1336,7 +1355,7 @@ export const restore = { post: post19, } -export const get32 = oc +export const get33 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1348,11 +1367,11 @@ export const get32 = oc .output(zGetAgentByAgentIdVersionsByVersionIdResponse) export const byVersionId = { - get: get32, + get: get33, restore, } -export const get33 = oc +export const get34 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1364,7 +1383,7 @@ export const get33 = oc .output(zGetAgentByAgentIdVersionsResponse) export const versions = { - get: get33, + get: get34, byVersionId, } @@ -1380,7 +1399,7 @@ export const delete7 = oc .input(z.object({ params: zDeleteAgentByAgentIdPath })) .output(zDeleteAgentByAgentIdResponse) -export const get34 = oc +export const get35 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1404,7 +1423,7 @@ export const put3 = oc export const byAgentId = { delete: delete7, - get: get34, + get: get35, put: put3, apiAccess, apiEnable, @@ -1431,7 +1450,7 @@ export const byAgentId = { versions, } -export const get35 = oc +export const get36 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1455,7 +1474,7 @@ export const post20 = oc .output(zPostAgentResponse) export const agent = { - get: get35, + get: get36, post: post20, inviteOptions, byAgentId, diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index d77f37403df..c7a35f003db 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -417,6 +417,11 @@ export type AgentReferencingWorkflowsResponse = { data?: Array } +export type SandboxInfoResponse = { + session_id: string + workspace_cwd: string +} + export type SandboxListResponse = { entries?: Array path: string @@ -2976,6 +2981,24 @@ export type GetAgentByAgentIdReferencingWorkflowsResponses = { export type GetAgentByAgentIdReferencingWorkflowsResponse = GetAgentByAgentIdReferencingWorkflowsResponses[keyof GetAgentByAgentIdReferencingWorkflowsResponses] +export type GetAgentByAgentIdSandboxData = { + body?: never + path: { + agent_id: string + } + query: { + conversation_id: string + } + url: '/agent/{agent_id}/sandbox' +} + +export type GetAgentByAgentIdSandboxResponses = { + 200: SandboxInfoResponse +} + +export type GetAgentByAgentIdSandboxResponse + = GetAgentByAgentIdSandboxResponses[keyof GetAgentByAgentIdSandboxResponses] + export type GetAgentByAgentIdSandboxFilesData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index f310b4e127d..52f46a4ae1f 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -185,6 +185,14 @@ export const zAgentPublishPayload = z.object({ version_note: z.string().nullish(), }) +/** + * SandboxInfoResponse + */ +export const zSandboxInfoResponse = z.object({ + session_id: z.string(), + workspace_cwd: z.string(), +}) + /** * SandboxReadResponse */ @@ -3365,6 +3373,19 @@ export const zGetAgentByAgentIdReferencingWorkflowsPath = z.object({ */ export const zGetAgentByAgentIdReferencingWorkflowsResponse = zAgentReferencingWorkflowsResponse +export const zGetAgentByAgentIdSandboxPath = z.object({ + agent_id: z.uuid(), +}) + +export const zGetAgentByAgentIdSandboxQuery = z.object({ + conversation_id: z.string().min(1), +}) + +/** + * Sandbox information returned + */ +export const zGetAgentByAgentIdSandboxResponse = zSandboxInfoResponse + export const zGetAgentByAgentIdSandboxFilesPath = z.object({ agent_id: z.uuid(), })