feat(api): wire sandbox into Agent V2 node execution pipeline

Integrate the ported sandbox system with Agent V2 node:

- Add DIFY_SANDBOX_CONTEXT_KEY to app_invoke_entities for passing
  sandbox through run_context without modifying graphon
- DifyNodeFactory._resolve_sandbox() extracts sandbox from run_context
  and passes it to AgentV2Node constructor
- AgentV2Node accepts optional sandbox parameter
- AgentV2ToolManager supports dual execution paths:
  - _invoke_tool_directly(): standard ToolEngine.generic_invoke (no sandbox)
  - _invoke_tool_in_sandbox(): delegates to SandboxBashSession.run_tool()
    which uses DifyCli to call back to Dify API from inside the sandbox
- Graceful fallback: if sandbox execution fails, logs warning and returns
  error message (does not crash the agent loop)

To enable sandbox for an Agent workflow:
1. Create a Sandbox via SandboxBuilder
2. Add it to run_context under DIFY_SANDBOX_CONTEXT_KEY
3. Agent V2 nodes will automatically use sandbox for tool execution

46 existing tests still pass.

Made-with: Cursor
This commit is contained in:
Yansong Zhang 2026-04-08 17:46:34 +08:00
parent 0c7e7e0c4e
commit d3d9f21cdf
4 changed files with 85 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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