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>
This commit is contained in:
林玮 (Jade Lin) 2026-07-03 17:43:57 +08:00 committed by GitHub
parent d3ed533716
commit 5e17af4530
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 223 additions and 34 deletions

View File

@ -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/<uuid:agent_id>/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/<uuid:agent_id>/sandbox/files")
class AgentAppSandboxListResource(Resource):
@console_ns.doc("list_agent_app_sandbox_files")

View File

@ -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)<br> |
| 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)<br> |
### [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, <br>**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 |

View File

@ -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"]

View File

@ -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,

View File

@ -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:

View File

@ -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,

View File

@ -417,6 +417,11 @@ export type AgentReferencingWorkflowsResponse = {
data?: Array<AgentReferencingWorkflowResponse>
}
export type SandboxInfoResponse = {
session_id: string
workspace_cwd: string
}
export type SandboxListResponse = {
entries?: Array<SandboxFileEntryResponse>
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: {

View File

@ -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(),
})