diff --git a/api/core/app/entities/app_invoke_entities.py b/api/core/app/entities/app_invoke_entities.py index 0cdbb5f50a..ebc31119ed 100644 --- a/api/core/app/entities/app_invoke_entities.py +++ b/api/core/app/entities/app_invoke_entities.py @@ -46,6 +46,9 @@ class InvokeFrom(StrEnum): return source_mapping.get(self, "dev") +DIFY_SANDBOX_CONTEXT_KEY = "_dify_sandbox" + + class DifyRunContext(BaseModel): tenant_id: str app_id: str diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index f088d7ae00..2bf60fc430 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -24,7 +24,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from configs import dify_config -from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext +from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DIFY_SANDBOX_CONTEXT_KEY, DifyRunContext from core.app.llm.model_access import build_dify_model_access, fetch_model_config from core.helper.code_executor.code_executor import ( CodeExecutionError, @@ -403,6 +403,7 @@ class DifyNodeFactory(NodeFactory): app_id=self._dify_context.app_id, ), "event_adapter": AgentV2EventAdapter(), + "sandbox": self._resolve_sandbox(), }, } node_init_kwargs = node_init_kwargs_factories.get(node_type, lambda: {})() @@ -414,6 +415,10 @@ class DifyNodeFactory(NodeFactory): **node_init_kwargs, ) + def _resolve_sandbox(self) -> Any: + """Resolve sandbox from run_context, if available.""" + return self.graph_init_params.run_context.get(DIFY_SANDBOX_CONTEXT_KEY) + @staticmethod def _validate_resolved_node_data(node_class: type[Node], node_data: BaseNodeData) -> BaseNodeData: """ diff --git a/api/core/workflow/nodes/agent_v2/node.py b/api/core/workflow/nodes/agent_v2/node.py index 5c386b581b..6b9a373aa8 100644 --- a/api/core/workflow/nodes/agent_v2/node.py +++ b/api/core/workflow/nodes/agent_v2/node.py @@ -72,6 +72,7 @@ class AgentV2Node(Node[AgentV2NodeData]): *, tool_manager: AgentV2ToolManager, event_adapter: AgentV2EventAdapter, + sandbox: Any | None = None, ) -> None: super().__init__( id=id, @@ -81,6 +82,7 @@ class AgentV2Node(Node[AgentV2NodeData]): ) self._tool_manager = tool_manager self._event_adapter = event_adapter + self._sandbox = sandbox @classmethod def version(cls) -> str: @@ -246,7 +248,9 @@ class AgentV2Node(Node[AgentV2NodeData]): max_iterations=self.node_data.max_iterations, context=context, agent_strategy=agent_strategy_enum, - tool_invoke_hook=self._tool_manager.create_workflow_tool_invoke_hook(context), + tool_invoke_hook=self._tool_manager.create_workflow_tool_invoke_hook( + context, sandbox=self._sandbox + ), ) outputs_gen = strategy.run( diff --git a/api/core/workflow/nodes/agent_v2/tool_manager.py b/api/core/workflow/nodes/agent_v2/tool_manager.py index 8cc1409dd9..f9931b89e9 100644 --- a/api/core/workflow/nodes/agent_v2/tool_manager.py +++ b/api/core/workflow/nodes/agent_v2/tool_manager.py @@ -87,36 +87,84 @@ class AgentV2ToolManager: self, context: ExecutionContext, workflow_call_depth: int = 0, + sandbox: Any | None = None, ) -> ToolInvokeHook: - """Create a ToolInvokeHook for workflow context (uses generic_invoke).""" + """Create a ToolInvokeHook for workflow context. + + When sandbox is provided, tools that support sandbox execution will run + inside the sandbox environment. Otherwise, falls back to generic_invoke. + """ def hook( tool: Tool, tool_args: dict[str, Any], tool_name: str, ) -> tuple[str, list[str], ToolInvokeMeta]: - tool_response = ToolEngine.generic_invoke( - tool=tool, - tool_parameters=tool_args, - user_id=context.user_id or "", - workflow_tool_callback=DifyWorkflowCallbackHandler(), - workflow_call_depth=workflow_call_depth, - app_id=context.app_id, - conversation_id=context.conversation_id, - ) + if sandbox is not None: + return self._invoke_tool_in_sandbox(sandbox, tool, tool_args, tool_name, context) - response_content = "" - for response in tool_response: - if response.type == ToolInvokeMessage.MessageType.TEXT: - assert isinstance(response.message, ToolInvokeMessage.TextMessage) - response_content += response.message.text - elif response.type == ToolInvokeMessage.MessageType.JSON: - if isinstance(response.message, ToolInvokeMessage.JsonMessage): - response_content += json.dumps(response.message.json_object, ensure_ascii=False) - elif response.type == ToolInvokeMessage.MessageType.LINK: - if isinstance(response.message, ToolInvokeMessage.TextMessage): - response_content += f"[Link: {response.message.text}]" - - return response_content, [], ToolInvokeMeta.empty() + return self._invoke_tool_directly(tool, tool_args, tool_name, context, workflow_call_depth) return hook + + def _invoke_tool_directly( + self, + tool: Tool, + tool_args: dict[str, Any], + tool_name: str, + context: ExecutionContext, + workflow_call_depth: int, + ) -> tuple[str, list[str], ToolInvokeMeta]: + """Invoke tool directly via ToolEngine (no sandbox).""" + tool_response = ToolEngine.generic_invoke( + tool=tool, + tool_parameters=tool_args, + user_id=context.user_id or "", + workflow_tool_callback=DifyWorkflowCallbackHandler(), + workflow_call_depth=workflow_call_depth, + app_id=context.app_id, + conversation_id=context.conversation_id, + ) + + response_content = "" + for response in tool_response: + if response.type == ToolInvokeMessage.MessageType.TEXT: + assert isinstance(response.message, ToolInvokeMessage.TextMessage) + response_content += response.message.text + elif response.type == ToolInvokeMessage.MessageType.JSON: + if isinstance(response.message, ToolInvokeMessage.JsonMessage): + response_content += json.dumps(response.message.json_object, ensure_ascii=False) + elif response.type == ToolInvokeMessage.MessageType.LINK: + if isinstance(response.message, ToolInvokeMessage.TextMessage): + response_content += f"[Link: {response.message.text}]" + + return response_content, [], ToolInvokeMeta.empty() + + @staticmethod + def _invoke_tool_in_sandbox( + sandbox: Any, + tool: Tool, + tool_args: dict[str, Any], + tool_name: str, + context: ExecutionContext, + ) -> tuple[str, list[str], ToolInvokeMeta]: + """Invoke tool inside a sandbox environment. + + Uses the sandbox's bash session to execute the tool via DifyCli, + which calls back to Dify's CLI API to perform the actual invocation. + """ + try: + from core.sandbox.bash.session import SandboxBashSession + + session = SandboxBashSession(sandbox) + result = session.run_tool( + tool_name=tool_name, + tool_args=tool_args, + tenant_id=context.tenant_id or "", + app_id=context.app_id or "", + user_id=context.user_id or "", + ) + return result.stdout.decode("utf-8", errors="replace"), [], ToolInvokeMeta.empty() + except Exception as e: + logger.warning("Sandbox tool invocation failed for %s, falling back to direct: %s", tool_name, e) + return f"Sandbox execution failed: {e}", [], ToolInvokeMeta.empty()