feat: sandbox session and dify cli

This commit is contained in:
Harry 2026-01-12 01:49:01 +08:00
parent ce0a59b60d
commit 3d2840edb6
12 changed files with 187 additions and 45 deletions

View File

@ -712,3 +712,7 @@ ANNOTATION_IMPORT_MAX_CONCURRENT=5
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21 SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000 SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30 SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
# Sandbox Dify CLI configuration
# Directory containing dify CLI binaries (dify-cli-<os>-<arch>). Defaults to api/bin when unset.
SANDBOX_DIFY_CLI_ROOT=

BIN
api/bin/dify-cli-darwin-amd64 Executable file

Binary file not shown.

BIN
api/bin/dify-cli-darwin-arm64 Executable file

Binary file not shown.

BIN
api/bin/dify-cli-linux-amd64 Executable file

Binary file not shown.

BIN
api/bin/dify-cli-linux-arm64 Executable file

Binary file not shown.

View File

@ -2,6 +2,7 @@ import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from pydantic import Field
from pydantic.fields import FieldInfo from pydantic.fields import FieldInfo
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
@ -82,6 +83,14 @@ class DifyConfig(
extra="ignore", extra="ignore",
) )
SANDBOX_DIFY_CLI_ROOT: str | None = Field(
default=None,
description=(
"Filesystem directory containing dify CLI binaries named dify-cli-<os>-<arch>. "
"Defaults to api/bin when unset."
),
)
# Before adding any config, # Before adding any config,
# please consider to arrange it in the proper config group of existed or added # please consider to arrange it in the proper config group of existed or added
# for better readability and maintainability. # for better readability and maintainability.

View File

@ -1,7 +1,9 @@
import logging import logging
from collections.abc import Mapping from collections.abc import Mapping
from io import BytesIO
from typing import Any from typing import Any
from core.sandbox import DIFY_CLI_PATH, DifyCliLocator
from core.virtual_environment.__base.virtual_environment import VirtualEnvironment from core.virtual_environment.__base.virtual_environment import VirtualEnvironment
from core.virtual_environment.sandbox_manager import SandboxManager from core.virtual_environment.sandbox_manager import SandboxManager
from core.workflow.graph_engine.layers.base import GraphEngineLayer from core.workflow.graph_engine.layers.base import GraphEngineLayer
@ -62,10 +64,29 @@ class SandboxLayer(GraphEngineLayer):
sandbox.metadata.id, sandbox.metadata.id,
sandbox.metadata.arch, sandbox.metadata.arch,
) )
self._upload_cli(sandbox)
except Exception as e: except Exception as e:
logger.exception("Failed to initialize sandbox") logger.exception("Failed to initialize sandbox")
raise SandboxInitializationError(f"Failed to initialize sandbox: {e}") from e raise SandboxInitializationError(f"Failed to initialize sandbox: {e}") from e
def _upload_cli(self, sandbox: VirtualEnvironment) -> None:
locator = DifyCliLocator()
binary = locator.resolve(sandbox.metadata.os, sandbox.metadata.arch)
sandbox.upload_file(DIFY_CLI_PATH, BytesIO(binary.path.read_bytes()))
connection_handle = sandbox.establish_connection()
try:
future = sandbox.run_command(connection_handle, ["chmod", "+x", DIFY_CLI_PATH])
result = future.result(timeout=10)
if result.exit_code not in (0, None):
stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
raise RuntimeError(f"Failed to mark dify CLI as executable: {stderr}")
logger.info("Dify CLI uploaded to sandbox, path=%s", DIFY_CLI_PATH)
finally:
sandbox.release_connection(connection_handle)
def on_event(self, event: GraphEngineEvent) -> None: def on_event(self, event: GraphEngineEvent) -> None:
pass pass

View File

@ -0,0 +1,27 @@
from core.sandbox.constants import (
DIFY_CLI_CONFIG_PATH,
DIFY_CLI_PATH,
DIFY_CLI_PATH_PATTERN,
SANDBOX_WORK_DIR,
)
from core.sandbox.dify_cli import (
DifyCliBinary,
DifyCliConfig,
DifyCliEnvConfig,
DifyCliLocator,
DifyCliToolConfig,
)
from core.sandbox.session import SandboxSession
__all__ = [
"DIFY_CLI_CONFIG_PATH",
"DIFY_CLI_PATH",
"DIFY_CLI_PATH_PATTERN",
"SANDBOX_WORK_DIR",
"DifyCliBinary",
"DifyCliConfig",
"DifyCliEnvConfig",
"DifyCliLocator",
"DifyCliToolConfig",
"SandboxSession",
]

View File

@ -0,0 +1,9 @@
from typing import Final
SANDBOX_WORK_DIR: Final[str] = "/work"
DIFY_CLI_PATH: Final[str] = "/work/.dify/bin/dify"
DIFY_CLI_PATH_PATTERN: Final[str] = "dify-cli-{os}-{arch}"
DIFY_CLI_CONFIG_PATH: Final[str] = "/work/config.json"

View File

@ -0,0 +1,95 @@
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, Field
from core.sandbox.constants import DIFY_CLI_PATH_PATTERN
from core.virtual_environment.__base.entities import Arch, OperatingSystem
if TYPE_CHECKING:
from core.tools.__base.tool import Tool
class DifyCliBinary(BaseModel):
operating_system: OperatingSystem = Field(alias="os")
arch: Arch
path: Path
model_config = {
"populate_by_name": True,
"arbitrary_types_allowed": True,
}
class DifyCliLocator:
def __init__(self, root: str | Path | None = None) -> None:
from configs import dify_config
if root is not None:
self._root = Path(root)
elif dify_config.SANDBOX_DIFY_CLI_ROOT:
self._root = Path(dify_config.SANDBOX_DIFY_CLI_ROOT)
else:
api_root = Path(__file__).resolve().parents[2]
self._root = api_root / "bin"
def resolve(self, operating_system: OperatingSystem, arch: Arch) -> DifyCliBinary:
filename = DIFY_CLI_PATH_PATTERN.format(os=operating_system.value, arch=arch.value)
candidate = self._root / filename
if not candidate.is_file():
raise FileNotFoundError(
f"dify CLI binary not found: {candidate}. Configure SANDBOX_DIFY_CLI_ROOT or ensure the file exists."
)
return DifyCliBinary(os=operating_system, arch=arch, path=candidate)
class DifyCliEnvConfig(BaseModel):
files_url: str
inner_api_url: str
inner_api_session_id: str
class DifyCliToolConfig(BaseModel):
provider_type: str
identity: dict[str, Any]
description: dict[str, Any]
parameters: list[dict[str, Any]]
@classmethod
def create_from_tool(cls, tool: Tool) -> DifyCliToolConfig:
return cls(
provider_type=tool.tool_provider_type().value,
identity=tool.entity.identity.model_dump(),
description=tool.entity.description.model_dump() if tool.entity.description else {},
parameters=[param.model_dump() for param in tool.entity.parameters],
)
class DifyCliConfig(BaseModel):
env: DifyCliEnvConfig
tools: list[DifyCliToolConfig]
@classmethod
def create(cls, session_id: str, tools: list[Tool]) -> DifyCliConfig:
from configs import dify_config
return cls(
env=DifyCliEnvConfig(
files_url=dify_config.FILES_URL,
inner_api_url=dify_config.CONSOLE_API_URL,
inner_api_session_id=session_id,
),
tools=[DifyCliToolConfig.create_from_tool(tool) for tool in tools],
)
__all__ = [
"DifyCliBinary",
"DifyCliConfig",
"DifyCliEnvConfig",
"DifyCliLocator",
"DifyCliToolConfig",
]

View File

@ -3,7 +3,10 @@ from __future__ import annotations
import json import json
import logging import logging
from io import BytesIO from io import BytesIO
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING
from core.sandbox.constants import DIFY_CLI_CONFIG_PATH, DIFY_CLI_PATH
from core.sandbox.dify_cli import DifyCliConfig
if TYPE_CHECKING: if TYPE_CHECKING:
from types import TracebackType from types import TracebackType
@ -43,15 +46,30 @@ class SandboxSession:
raise RuntimeError(f"Sandbox not found for workflow_execution_id={self._workflow_execution_id}") raise RuntimeError(f"Sandbox not found for workflow_execution_id={self._workflow_execution_id}")
session = InnerApiSessionManager().create(tenant_id=self._tenant_id, user_id=self._user_id) session = InnerApiSessionManager().create(tenant_id=self._tenant_id, user_id=self._user_id)
self._session_id = session.id
try: try:
_upload_and_init_dify_cli(sandbox, self._tools, session.id) config = DifyCliConfig.create(self._session_id, self._tools)
config_json = json.dumps(config.model_dump(mode="json"), ensure_ascii=False)
sandbox.upload_file(DIFY_CLI_CONFIG_PATH, BytesIO(config_json.encode("utf-8")))
connection_handle = sandbox.establish_connection()
try:
future = sandbox.run_command(connection_handle, [DIFY_CLI_PATH, "init"])
result = future.result(timeout=30)
if result.exit_code not in (0, None):
stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
raise RuntimeError(f"Failed to initialize Dify CLI in sandbox: {stderr}")
finally:
sandbox.release_connection(connection_handle)
except Exception: except Exception:
InnerApiSessionManager().delete(session.id) InnerApiSessionManager().delete(session.id)
self._session_id = None
raise raise
self._sandbox = sandbox self._sandbox = sandbox
self._session_id = session.id
self._bash_tool = SandboxBashTool(sandbox=sandbox, tenant_id=self._tenant_id) self._bash_tool = SandboxBashTool(sandbox=sandbox, tenant_id=self._tenant_id)
return self return self
@ -82,44 +100,3 @@ class SandboxSession:
InnerApiSessionManager().delete(self._session_id) InnerApiSessionManager().delete(self._session_id)
logger.debug("Cleaned up SandboxSession session_id=%s", self._session_id) logger.debug("Cleaned up SandboxSession session_id=%s", self._session_id)
self._session_id = None self._session_id = None
def _upload_and_init_dify_cli(sandbox: VirtualEnvironment, tools: list[Tool], session_id: str) -> None:
from configs import dify_config
config = {
"env": {
"files_url": dify_config.FILES_URL,
"inner_api_url": dify_config.CONSOLE_API_URL,
"inner_api_session_id": session_id,
},
"tools": _serialize_tools(tools),
}
config_json = json.dumps(config, ensure_ascii=False)
config_path = f"/tmp/dify-init-{session_id}.json"
sandbox.upload_file(config_path, BytesIO(config_json.encode("utf-8")))
connection_handle = sandbox.establish_connection()
try:
future = sandbox.run_command(connection_handle, ["dify", "init", config_path])
result = future.result(timeout=30)
if result.exit_code != 0:
stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
raise RuntimeError(f"Failed to initialize Dify CLI in sandbox: {stderr}")
finally:
sandbox.release_connection(connection_handle)
def _serialize_tools(tools: list[Tool]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for tool in tools:
tool_config = tool.entity.model_dump()
tool_config["provider_type"] = tool.tool_provider_type().value
tool_config["credential_type"] = tool.runtime.credential_type.value if tool.runtime else "default"
tool_config["credential_id"] = tool.runtime.tool_id if tool.runtime else tool.entity.identity.provider
result.append(tool_config)
return result

View File

@ -13,7 +13,6 @@ from sqlalchemy import select
from core.agent.entities import AgentLog, AgentResult, AgentToolEntity, ExecutionContext from core.agent.entities import AgentLog, AgentResult, AgentToolEntity, ExecutionContext
from core.agent.patterns import StrategyFactory from core.agent.patterns import StrategyFactory
from core.agent.sandbox_session import SandboxSession
from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity
from core.file import File, FileTransferMethod, FileType, file_manager from core.file import File, FileTransferMethod, FileType, file_manager
from core.helper.code_executor import CodeExecutor, CodeLanguage from core.helper.code_executor import CodeExecutor, CodeLanguage
@ -51,6 +50,7 @@ from core.model_runtime.utils.encoders import jsonable_encoder
from core.prompt.entities.advanced_prompt_entities import CompletionModelPromptTemplate, MemoryConfig from core.prompt.entities.advanced_prompt_entities import CompletionModelPromptTemplate, MemoryConfig
from core.prompt.utils.prompt_message_util import PromptMessageUtil from core.prompt.utils.prompt_message_util import PromptMessageUtil
from core.rag.entities.citation_metadata import RetrievalSourceMetadata from core.rag.entities.citation_metadata import RetrievalSourceMetadata
from core.sandbox import SandboxSession
from core.tools.__base.tool import Tool from core.tools.__base.tool import Tool
from core.tools.signature import sign_upload_file from core.tools.signature import sign_upload_file
from core.tools.tool_manager import ToolManager from core.tools.tool_manager import ToolManager