mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 13:51:25 +08:00
1390 lines
66 KiB
Python
1390 lines
66 KiB
Python
import importlib
|
|
import pkgutil
|
|
import secrets
|
|
from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence
|
|
from copy import copy
|
|
from dataclasses import dataclass
|
|
from functools import lru_cache
|
|
from typing import TYPE_CHECKING, Any, Protocol, cast, final, override, runtime_checkable
|
|
|
|
from sqlalchemy import select
|
|
|
|
from configs import dify_config
|
|
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
|
|
from core.app.llm.model_access import build_dify_model_access, fetch_model_config
|
|
from core.credit_usage import created_by_from_app_type
|
|
from core.db.session_factory import get_session_maker, session_factory
|
|
from core.dify_builder.input_schema import start_schema
|
|
from core.dify_builder.real_call_consent import ConfirmedToolGuard
|
|
from core.file import remote_fetcher
|
|
from core.helper.code_executor.code_executor import (
|
|
CodeExecutionError,
|
|
CodeExecutor,
|
|
)
|
|
from core.helper.ssrf_proxy import graphon_ssrf_proxy
|
|
from core.memory.token_buffer_memory import TokenBufferMemory
|
|
from core.model_manager import ModelInstance
|
|
from core.prompt.entities.advanced_prompt_entities import MemoryConfig
|
|
from core.trigger.constants import TRIGGER_NODE_TYPES
|
|
from core.workflow.human_input_adapter import adapt_node_config_for_graph, parse_human_input_delivery_methods
|
|
from core.workflow.llm_environment_variable import (
|
|
parse_llm_model_selector,
|
|
resolve_llm_model_config,
|
|
should_resolve_llm_model_selector,
|
|
)
|
|
from core.workflow.llm_node import DifyLLMNode
|
|
from core.workflow.node_runtime import (
|
|
DifyFileReferenceFactory,
|
|
DifyHumanInputNodeRuntime,
|
|
DifyPreparedLLM,
|
|
DifyPreparedPollingLLM,
|
|
DifyPromptMessageSerializer,
|
|
DifyRetrieverAttachmentLoader,
|
|
DifyToolFileManager,
|
|
DifyToolNodeRuntime,
|
|
build_dify_llm_file_saver,
|
|
build_knowledge_fs_node_dependencies,
|
|
)
|
|
from core.workflow.nodes.agent.message_transformer import AgentMessageTransformer
|
|
from core.workflow.nodes.agent.plugin_strategy_adapter import (
|
|
PluginAgentStrategyPresentationProvider,
|
|
PluginAgentStrategyResolver,
|
|
)
|
|
from core.workflow.nodes.agent.runtime_support import AgentRuntimeSupport
|
|
from core.workflow.nodes.agent_v2.dify_tools_builder import WorkflowAgentDifyToolsBuilder
|
|
from core.workflow.nodes.agent_v2.output_adapter import WorkflowAgentOutputAdapter
|
|
from core.workflow.nodes.agent_v2.runtime_request_builder import WorkflowAgentRuntimeRequestBuilder
|
|
from core.workflow.nodes.human_input.callback import DifyHITLCallback
|
|
from core.workflow.nodes.human_input.entities import HumanInputNodeData as DifyHumanInputNodeData
|
|
from core.workflow.nodes.human_input_v2.node import HumanInputNode as HumanInputV2Node
|
|
from core.workflow.system_variables import SystemVariableKey, get_system_text, system_variable_selector
|
|
from core.workflow.template_rendering import CodeExecutorJinja2TemplateRenderer
|
|
from core.workflow.workflow_tool_node import DifyWorkflowToolNode
|
|
from domain.workflow.agent_node import is_dify_agent_node_data
|
|
from extensions.application_services.data_sources import build_data_source_credentials
|
|
from graphon.entities.base_node_data import BaseNodeData
|
|
from graphon.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter
|
|
from graphon.enums import BuiltinNodeTypes, NodeExecutionType, NodeType
|
|
from graphon.file.file_manager import file_manager
|
|
from graphon.graph.graph import NodeFactory
|
|
from graphon.model_runtime.memory import PromptMessageMemory
|
|
from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel
|
|
from graphon.nodes.base.node import Node
|
|
from graphon.nodes.code.code_node import CodeExecutorProtocol
|
|
from graphon.nodes.code.entities import CodeLanguage
|
|
from graphon.nodes.code.limits import CodeNodeLimits
|
|
from graphon.nodes.document_extractor import UnstructuredApiConfig
|
|
from graphon.nodes.end.end_node import EndNode
|
|
from graphon.nodes.http_request import build_http_request_config
|
|
from graphon.nodes.llm.entities import LLMNodeData
|
|
from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData
|
|
from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData
|
|
from graphon.nodes.start.start_node import StartNode
|
|
from graphon.nodes.tool.entities import ToolProviderType
|
|
from graphon.nodes.tool.tool_node import ToolNode
|
|
from graphon.variables.segments import ArrayObjectSegment, ObjectSegment
|
|
from models.model import Conversation
|
|
from runtime_contracts.builder.execution import (
|
|
BuilderExecutionContext,
|
|
BuilderExecutionPolicyError,
|
|
BuilderExecutionRecorder,
|
|
ConfirmedToolExecutionContext,
|
|
JsonToolExecutionContext,
|
|
validate_execution_context,
|
|
)
|
|
from services.dify_builder.application import gmail_read_policy
|
|
from services.dify_builder.application.execution_policy import (
|
|
RestrictedAdmissionSnapshot,
|
|
admit_restricted_workflow,
|
|
)
|
|
from services.dify_builder.effect_mock import mocked_node_class
|
|
from services.dify_builder.json_tool_execution import admit_json_workflow
|
|
from services.dify_builder.json_tool_runtime import QualifiedJsonToolNodeRuntime
|
|
from services.dify_builder.native_testing.context_binding import NativeContextUnavailable
|
|
from services.dify_builder.native_testing.runtime_bridge import NativeRuntimeBridge
|
|
from services.dify_builder.restricted_execution import (
|
|
RESTRICTED_HTTP_CONFIG,
|
|
DeniedHttpFiles,
|
|
RestrictedHttpClient,
|
|
validate_restricted_node,
|
|
validate_restricted_raw_node,
|
|
)
|
|
from services.dify_builder.revision import node_config_digest
|
|
from services.dify_builder.template_execution import MAX_OUTPUT_BYTES, BoundedJinja2TemplateRenderer
|
|
from services.human_input_v2.composition import WorkflowHumanInputRuntime
|
|
from services.knowledge_fs.knowledge_retrieval_v2.automatic_metadata_filter import (
|
|
KnowledgeFSAutomaticMetadataFilterExtractor,
|
|
)
|
|
from services.knowledge_fs.knowledge_retrieval_v2.entities import KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE
|
|
from services.workflow_runtime.agent_binding import WorkflowAgentBindingResolver
|
|
from services.workflow_runtime.agent_node import DifyAgentNode
|
|
|
|
if TYPE_CHECKING:
|
|
from core.tools.workflow_as_tool.tool import WorkflowTool
|
|
from graphon.runtime import InitParams, RuntimeState
|
|
from services.workflow_runtime.base_runner import WorkflowRunDriver
|
|
|
|
LATEST_VERSION = "latest"
|
|
_START_NODE_TYPES: frozenset[NodeType] = frozenset(
|
|
(BuiltinNodeTypes.START, BuiltinNodeTypes.DATASOURCE, *TRIGGER_NODE_TYPES)
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class DifyGraphInitContext:
|
|
"""Explicit graph-init values owned by the workflow layer.
|
|
|
|
Dify is gradually removing direct `InitParams` construction from its
|
|
production call sites. Keep the translation here until `graphon` exposes an
|
|
equivalent explicit API.
|
|
"""
|
|
|
|
workflow_id: str
|
|
graph_config: Mapping[str, Any]
|
|
run_context: Mapping[str, Any]
|
|
call_depth: int
|
|
execution_driver: "WorkflowRunDriver | None" = None
|
|
|
|
def to_graph_init_params(self) -> "InitParams":
|
|
from graphon.runtime import InitParams
|
|
|
|
return InitParams(
|
|
workflow_id=self.workflow_id,
|
|
graph_config=self.graph_config,
|
|
run_context=self.run_context,
|
|
call_depth=self.call_depth,
|
|
)
|
|
|
|
|
|
def _import_node_package(package_name: str, *, excluded_modules: frozenset[str] = frozenset()) -> None:
|
|
package = importlib.import_module(package_name)
|
|
for _, module_name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + "."):
|
|
if module_name in excluded_modules:
|
|
continue
|
|
importlib.import_module(module_name)
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def register_nodes() -> None:
|
|
"""Import production node modules so they self-register with ``Node``."""
|
|
_import_node_package("graphon.nodes")
|
|
_import_node_package("core.workflow.nodes")
|
|
_import_node_package("services.knowledge_fs.knowledge_retrieval_v2")
|
|
|
|
|
|
def get_node_type_classes_mapping() -> Mapping[NodeType, Mapping[str, type[Node]]]:
|
|
"""Return a read-only snapshot of the current production node registry.
|
|
|
|
The workflow layer owns node bootstrap because it must compose built-in
|
|
`graphon.nodes.*` implementations with workflow-local nodes under
|
|
`core.workflow.nodes.*`. Keeping this import side effect here avoids
|
|
reintroducing registry bootstrapping into lower-level graph primitives.
|
|
"""
|
|
register_nodes()
|
|
return Node.get_node_type_classes_mapping()
|
|
|
|
|
|
def resolve_workflow_node_class(
|
|
*,
|
|
node_type: NodeType,
|
|
node_version: str,
|
|
node_data: Mapping[str, Any] | BaseNodeData | None = None,
|
|
) -> type[Node]:
|
|
"""Resolve the production node class for the requested type/version."""
|
|
node_mapping = get_node_type_classes_mapping().get(node_type)
|
|
if not node_mapping:
|
|
raise ValueError(f"No class mapping found for node type: {node_type}")
|
|
|
|
# Historical Agent nodes used version=2 for their tool-parameter format.
|
|
# Only the explicit kind marker identifies the newer Dify Agent node.
|
|
if (
|
|
node_data is not None
|
|
and node_type == BuiltinNodeTypes.AGENT
|
|
and node_version == "2"
|
|
and not is_dify_agent_node_data(node_data)
|
|
):
|
|
node_version = "1"
|
|
|
|
latest_node_class = node_mapping.get(LATEST_VERSION)
|
|
matched_node_class = node_mapping.get(node_version)
|
|
node_class = matched_node_class or latest_node_class
|
|
if not node_class:
|
|
raise ValueError(f"No latest version class found for node type: {node_type}")
|
|
return node_class
|
|
|
|
|
|
def is_start_node_type(node_type: NodeType) -> bool:
|
|
"""Return True when the node type can serve as a workflow entry point."""
|
|
return node_type in _START_NODE_TYPES
|
|
|
|
|
|
def get_default_root_node_id(graph_config: Mapping[str, Any]) -> str:
|
|
"""Resolve the default entry node for a persisted top-level workflow graph.
|
|
|
|
This workflow-layer helper depends on start-node semantics defined by
|
|
`is_start_node_type`, so it intentionally lives next to the node registry
|
|
instead of in the raw `graphon.entities.graph_config` schema module.
|
|
"""
|
|
nodes = graph_config.get("nodes")
|
|
if not isinstance(nodes, list):
|
|
raise ValueError("nodes in workflow graph must be a list")
|
|
|
|
for node in nodes:
|
|
if not isinstance(node, Mapping):
|
|
continue
|
|
|
|
if node.get("type") == "custom-note":
|
|
continue
|
|
|
|
node_id = node.get("id")
|
|
data = node.get("data")
|
|
if not isinstance(node_id, str) or not isinstance(data, Mapping):
|
|
continue
|
|
|
|
node_type = data.get("type")
|
|
if isinstance(node_type, str) and is_start_node_type(node_type):
|
|
return node_id
|
|
|
|
raise ValueError("Unable to determine default root node ID from workflow graph")
|
|
|
|
|
|
class _LazyNodeTypeClassesMapping(MutableMapping[NodeType, Mapping[str, type[Node]]]):
|
|
"""Mutable dict-like view over the current node registry."""
|
|
|
|
def __init__(self) -> None:
|
|
self._cached_snapshot: dict[NodeType, Mapping[str, type[Node]]] = {}
|
|
self._cached_version = -1
|
|
self._deleted: set[NodeType] = set()
|
|
self._overrides: dict[NodeType, Mapping[str, type[Node]]] = {}
|
|
|
|
def _snapshot(self) -> dict[NodeType, Mapping[str, type[Node]]]:
|
|
current_version = Node.get_registry_version()
|
|
if self._cached_version != current_version:
|
|
self._cached_snapshot = dict(get_node_type_classes_mapping())
|
|
self._cached_version = current_version
|
|
if not self._deleted and not self._overrides:
|
|
return self._cached_snapshot
|
|
|
|
snapshot = {key: value for key, value in self._cached_snapshot.items() if key not in self._deleted}
|
|
snapshot.update(self._overrides)
|
|
return snapshot
|
|
|
|
@override
|
|
def __getitem__(self, key: NodeType) -> Mapping[str, type[Node]]:
|
|
return self._snapshot()[key]
|
|
|
|
@override
|
|
def __setitem__(self, key: NodeType, value: Mapping[str, type[Node]]) -> None:
|
|
self._deleted.discard(key)
|
|
self._overrides[key] = value
|
|
|
|
@override
|
|
def __delitem__(self, key: NodeType) -> None:
|
|
if key in self._overrides:
|
|
del self._overrides[key]
|
|
return
|
|
if key in self._cached_snapshot:
|
|
self._deleted.add(key)
|
|
return
|
|
raise KeyError(key)
|
|
|
|
@override
|
|
def __iter__(self) -> Iterator[NodeType]:
|
|
return iter(self._snapshot())
|
|
|
|
@override
|
|
def __len__(self) -> int:
|
|
return len(self._snapshot())
|
|
|
|
|
|
# Keep the canonical node-class mapping in the workflow layer that also bootstraps
|
|
# legacy `core.workflow.nodes.*` registrations.
|
|
NODE_TYPE_CLASSES_MAPPING: MutableMapping[NodeType, Mapping[str, type[Node]]] = _LazyNodeTypeClassesMapping()
|
|
|
|
|
|
type LLMCompatibleNodeData = LLMNodeData | QuestionClassifierNodeData | ParameterExtractorNodeData
|
|
|
|
|
|
def fetch_memory(
|
|
*,
|
|
conversation_id: str | None,
|
|
app_id: str,
|
|
node_data_memory: MemoryConfig | None,
|
|
model_instance: ModelInstance,
|
|
) -> TokenBufferMemory | None:
|
|
"""Build prompt memory for node construction without requiring Flask-local state."""
|
|
if not node_data_memory or not conversation_id:
|
|
return None
|
|
|
|
# Node construction can happen in graph initialization paths where Flask's
|
|
# app context is not active. Use the app-configured session factory instead
|
|
# of resolving db.engine through Flask-SQLAlchemy's current_app proxy.
|
|
with session_factory.create_session() as session:
|
|
stmt = select(Conversation).where(Conversation.app_id == app_id, Conversation.id == conversation_id)
|
|
conversation = session.scalar(stmt)
|
|
if not conversation:
|
|
return None
|
|
|
|
return TokenBufferMemory(conversation=conversation, model_instance=model_instance)
|
|
|
|
|
|
# The exact classes a confirmed Builder Tool run may construct.
|
|
_CONFIRMED_CLASSES: Mapping[str, type[Node]] = {"start": StartNode, "tool": ToolNode, "end": EndNode}
|
|
|
|
|
|
@runtime_checkable
|
|
class ConfirmedToolGuardIssuer(BuilderExecutionRecorder, Protocol):
|
|
"""A launch recorder that can also issue the service-owned guard for this Run.
|
|
|
|
Implemented by the Builder service for one claimed launch identity: it
|
|
claims the grant at the given actual native IDs and returns the service's
|
|
guard, or refuses. The factory never builds or reconstructs a guard itself.
|
|
"""
|
|
|
|
def confirmed_guard(
|
|
self, *, node_id: str, native_run_id: str, native_node_execution_id: str
|
|
) -> ConfirmedToolGuard: ...
|
|
|
|
|
|
class _DeniedConfirmedToolFiles:
|
|
"""Tool file owner for a confirmed run: file messages are refused, never stored."""
|
|
|
|
def create_file_by_raw(self, *, file_binary: bytes, mimetype: str, filename: str | None = None) -> Any:
|
|
raise BuilderExecutionPolicyError("unsupported_tool_file")
|
|
|
|
def get_file_generator_by_tool_file_id(self, tool_file_id: str) -> Any:
|
|
raise BuilderExecutionPolicyError("unsupported_tool_file")
|
|
|
|
|
|
class DefaultWorkflowCodeExecutor:
|
|
def execute(
|
|
self,
|
|
*,
|
|
language: CodeLanguage,
|
|
code: str,
|
|
inputs: Mapping[str, Any],
|
|
) -> Mapping[str, Any]:
|
|
return CodeExecutor.execute_workflow_code_template(
|
|
language=language,
|
|
code=code,
|
|
inputs=inputs,
|
|
)
|
|
|
|
def is_execution_error(self, error: Exception) -> bool:
|
|
return isinstance(error, CodeExecutionError)
|
|
|
|
|
|
@final
|
|
class DifyNodeFactory(NodeFactory):
|
|
"""
|
|
Compose native nodes from the production registry. Builder restricted launches
|
|
receive only exact admitted implementations and their restricted collaborators.
|
|
"""
|
|
|
|
@classmethod
|
|
def from_graph_init_context(
|
|
cls,
|
|
*,
|
|
graph_init_context: DifyGraphInitContext,
|
|
runtime_state: "RuntimeState",
|
|
execution_recorder: BuilderExecutionRecorder | None = None,
|
|
native_runtime: NativeRuntimeBridge | None = None,
|
|
human_input_run_context: Mapping[str, Any] | DifyRunContext | None = None,
|
|
use_workflow_tool_containers: bool = True,
|
|
workflow_tools: dict[str, "WorkflowTool"] | None = None,
|
|
) -> "DifyNodeFactory":
|
|
"""Bridge Dify's explicit init context into the current `graphon` API."""
|
|
return cls(
|
|
init_params=graph_init_context.to_graph_init_params(),
|
|
runtime_state=runtime_state,
|
|
human_input_run_context=human_input_run_context,
|
|
use_workflow_tool_containers=use_workflow_tool_containers,
|
|
execution_driver=graph_init_context.execution_driver,
|
|
execution_recorder=execution_recorder,
|
|
native_runtime=native_runtime,
|
|
workflow_tools=workflow_tools,
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
init_params: "InitParams",
|
|
runtime_state: "RuntimeState",
|
|
human_input_run_context: Mapping[str, Any] | DifyRunContext | None = None,
|
|
use_workflow_tool_containers: bool = True,
|
|
workflow_tools: dict[str, "WorkflowTool"] | None = None,
|
|
execution_driver: "WorkflowRunDriver | None" = None,
|
|
*,
|
|
execution_recorder: BuilderExecutionRecorder | None = None,
|
|
native_runtime: NativeRuntimeBridge | None = None,
|
|
_request_hmac_key: bytes | None = None,
|
|
) -> None:
|
|
self.init_params = init_params
|
|
self._builder_init_params = init_params
|
|
self.runtime_state = runtime_state
|
|
self._dify_context = self._resolve_dify_context(init_params.run_context)
|
|
self._use_workflow_tool_containers = use_workflow_tool_containers
|
|
self._execution_driver = execution_driver
|
|
self.workflow_tools = workflow_tools if workflow_tools is not None else {}
|
|
self._human_input_run_context = (
|
|
self._dify_context
|
|
if human_input_run_context is None
|
|
else (
|
|
human_input_run_context
|
|
if isinstance(human_input_run_context, DifyRunContext)
|
|
else self._resolve_dify_context(human_input_run_context)
|
|
)
|
|
)
|
|
self.execution_recorder = execution_recorder
|
|
self._native_runtime = native_runtime
|
|
if native_runtime is not None and self._dify_context.native_test_ref is None:
|
|
raise NativeContextUnavailable("native_context_missing")
|
|
if self._dify_context.native_test_required or self._dify_context.native_test_ref is not None:
|
|
ref = self._dify_context.native_test_ref
|
|
if ref is None:
|
|
raise NativeContextUnavailable("native_context_missing")
|
|
if native_runtime is None:
|
|
raise NativeContextUnavailable("native_runtime_unavailable")
|
|
context = native_runtime.resolve(ref)
|
|
if self._dify_context.builder_execution is not None or (
|
|
self._dify_context.tenant_id,
|
|
self._dify_context.app_id,
|
|
self._dify_context.user_id,
|
|
init_params.workflow_id,
|
|
init_params.call_depth,
|
|
dict(init_params.graph_config),
|
|
) != (
|
|
context.tenant_id,
|
|
context.app_id,
|
|
context.actor_id,
|
|
context.workflow_id,
|
|
context.call_depth,
|
|
context.graph,
|
|
):
|
|
raise NativeContextUnavailable("native_context_mismatch")
|
|
return
|
|
context = self._dify_context.builder_execution
|
|
|
|
# Exhaustive context dispatch, before ordinary eager setup. A Builder
|
|
# context never falls through to native unrestricted construction.
|
|
match context:
|
|
case ConfirmedToolExecutionContext():
|
|
self._initialize_confirmed_tool_runtime(context)
|
|
return
|
|
case JsonToolExecutionContext():
|
|
self._initialize_json_tool_runtime(context)
|
|
return
|
|
case BuilderExecutionContext():
|
|
self._initialize_restricted_runtime(context, _request_hmac_key)
|
|
return
|
|
case None:
|
|
pass
|
|
case _:
|
|
raise BuilderExecutionPolicyError("unsupported_execution_policy")
|
|
self._code_executor: CodeExecutorProtocol = DefaultWorkflowCodeExecutor()
|
|
self._code_limits = CodeNodeLimits(
|
|
max_string_length=dify_config.CODE_MAX_STRING_LENGTH,
|
|
max_number=dify_config.CODE_MAX_NUMBER,
|
|
min_number=dify_config.CODE_MIN_NUMBER,
|
|
max_precision=dify_config.CODE_MAX_PRECISION,
|
|
max_depth=dify_config.CODE_MAX_DEPTH,
|
|
max_number_array_length=dify_config.CODE_MAX_NUMBER_ARRAY_LENGTH,
|
|
max_string_array_length=dify_config.CODE_MAX_STRING_ARRAY_LENGTH,
|
|
max_object_array_length=dify_config.CODE_MAX_OBJECT_ARRAY_LENGTH,
|
|
)
|
|
self._jinja2_template_renderer = CodeExecutorJinja2TemplateRenderer()
|
|
self._template_transform_max_output_length = dify_config.TEMPLATE_TRANSFORM_MAX_LENGTH
|
|
self._http_request_http_client = graphon_ssrf_proxy
|
|
self._remote_file_http_client = remote_fetcher.graphon_remote_file_fetcher
|
|
self._bound_tool_file_manager_factory = lambda: DifyToolFileManager(
|
|
self._dify_context,
|
|
conversation_id_getter=self._conversation_id,
|
|
)
|
|
self._file_reference_factory = DifyFileReferenceFactory(self._dify_context)
|
|
self._prompt_message_serializer = DifyPromptMessageSerializer()
|
|
self._retriever_attachment_loader = DifyRetrieverAttachmentLoader(
|
|
file_reference_factory=self._file_reference_factory,
|
|
)
|
|
self._llm_file_saver = build_dify_llm_file_saver(
|
|
run_context=self._dify_context,
|
|
http_client=self._remote_file_http_client,
|
|
conversation_id_getter=self._conversation_id,
|
|
)
|
|
self._human_input_runtime = DifyHumanInputNodeRuntime(
|
|
self._human_input_run_context,
|
|
workflow_execution_id_getter=lambda: get_system_text(
|
|
self.runtime_state.variable_pool,
|
|
SystemVariableKey.WORKFLOW_EXECUTION_ID,
|
|
),
|
|
conversation_id_getter=self._conversation_id,
|
|
)
|
|
self._tool_runtime = DifyToolNodeRuntime(
|
|
self._dify_context,
|
|
workflow_id=self.init_params.workflow_id,
|
|
execution_driver=execution_driver,
|
|
workflow_tools=self.workflow_tools,
|
|
)
|
|
self._http_request_file_manager = file_manager
|
|
self._document_extractor_unstructured_api_config = UnstructuredApiConfig(
|
|
api_url=dify_config.UNSTRUCTURED_API_URL,
|
|
api_key=dify_config.UNSTRUCTURED_API_KEY or "",
|
|
)
|
|
self._http_request_config = build_http_request_config(
|
|
max_connect_timeout=dify_config.HTTP_REQUEST_MAX_CONNECT_TIMEOUT,
|
|
max_read_timeout=dify_config.HTTP_REQUEST_MAX_READ_TIMEOUT,
|
|
max_write_timeout=dify_config.HTTP_REQUEST_MAX_WRITE_TIMEOUT,
|
|
max_binary_size=dify_config.HTTP_REQUEST_NODE_MAX_BINARY_SIZE,
|
|
max_text_size=dify_config.HTTP_REQUEST_NODE_MAX_TEXT_SIZE,
|
|
ssl_verify=dify_config.HTTP_REQUEST_NODE_SSL_VERIFY,
|
|
ssrf_default_max_retries=dify_config.SSRF_DEFAULT_MAX_RETRIES,
|
|
)
|
|
|
|
self._llm_credentials_provider, self._llm_model_factory = build_dify_model_access(self._dify_context)
|
|
self._agent_strategy_resolver = PluginAgentStrategyResolver()
|
|
self._agent_strategy_presentation_provider = PluginAgentStrategyPresentationProvider()
|
|
self._agent_runtime_support = AgentRuntimeSupport()
|
|
self._agent_message_transformer = AgentMessageTransformer()
|
|
|
|
def _require_builder_owner(
|
|
self, context: BuilderExecutionContext | ConfirmedToolExecutionContext | JsonToolExecutionContext
|
|
) -> None:
|
|
recorder = self.execution_recorder
|
|
if recorder is None or not recorder.healthy:
|
|
raise BuilderExecutionPolicyError("missing_execution_recorder")
|
|
if (
|
|
self._dify_context.tenant_id,
|
|
self._dify_context.app_id,
|
|
self._dify_context.user_id,
|
|
self.init_params.workflow_id,
|
|
self.init_params.call_depth,
|
|
) != (context.tenant_id, context.app_id, context.actor_id, context.workflow_id, 0):
|
|
raise BuilderExecutionPolicyError("execution_owner_mismatch")
|
|
|
|
def _admission_snapshot(self) -> RestrictedAdmissionSnapshot:
|
|
graph = dict(self.init_params.graph_config)
|
|
return RestrictedAdmissionSnapshot(
|
|
app_mode="workflow",
|
|
workflow_kind="standard",
|
|
graph=graph,
|
|
input_schema=start_schema(graph),
|
|
features={},
|
|
has_environment_variables=False,
|
|
has_conversation_variables=False,
|
|
has_external_tracing=False,
|
|
)
|
|
|
|
def _initialize_restricted_runtime(self, context: BuilderExecutionContext, request_hmac_key: bytes | None) -> None:
|
|
self._require_builder_owner(context)
|
|
snapshot = self._admission_snapshot()
|
|
# Check every raw class/config binding before concrete validation.
|
|
for node_config in snapshot.graph.get("nodes", []):
|
|
self._validate_restricted_raw_config(node_config)
|
|
admit_restricted_workflow(context, snapshot)
|
|
self._request_hmac_key = request_hmac_key if request_hmac_key is not None else secrets.token_bytes(32)
|
|
|
|
def _initialize_confirmed_tool_runtime(self, context: ConfirmedToolExecutionContext) -> None:
|
|
"""Admit exactly the reserved Start → Tool → End graph and one request-scoped Tool runtime.
|
|
|
|
Only ``DifyToolNodeRuntime`` is constructed, with the service-owned guard
|
|
issuer from the launch recorder. Graphon binds a node execution ID only
|
|
at ``get_runtime``; the closure then reads this run's actual native Run
|
|
ID from the graph runtime state and asks the service to claim the grant
|
|
and build its guard, before any ToolManager resolution.
|
|
"""
|
|
checked = validate_execution_context(context.model_dump(mode="json"))
|
|
if not isinstance(checked, ConfirmedToolExecutionContext) or checked != context:
|
|
raise BuilderExecutionPolicyError("invalid_execution_context")
|
|
self._require_builder_owner(context)
|
|
recorder = self.execution_recorder
|
|
if not isinstance(recorder, ConfirmedToolGuardIssuer):
|
|
raise BuilderExecutionPolicyError("missing_confirmed_tool_guard")
|
|
snapshot = self._admission_snapshot()
|
|
for node_config in snapshot.graph.get("nodes", []):
|
|
self._validate_confirmed_raw_config(node_config)
|
|
admission = gmail_read_policy.admit_get_message_workflow(snapshot)
|
|
if admission.nodes != context.admitted_nodes:
|
|
raise BuilderExecutionPolicyError("node_binding_mismatch")
|
|
|
|
def issue_guard(node_id: str, node_execution_id: str) -> ConfirmedToolGuard:
|
|
native_run_id = get_system_text(self.runtime_state.variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID)
|
|
if not native_run_id:
|
|
raise BuilderExecutionPolicyError("execution_launch_mismatch")
|
|
return recorder.confirmed_guard(
|
|
node_id=node_id, native_run_id=native_run_id, native_node_execution_id=node_execution_id
|
|
)
|
|
|
|
self._confirmed_tool_runtime = DifyToolNodeRuntime(self._dify_context, confirmed_guard_factory=issue_guard)
|
|
|
|
def _initialize_json_tool_runtime(self, context: JsonToolExecutionContext) -> None:
|
|
"""Admit the original three-node graph before native Tool data adaptation."""
|
|
from services.dify_builder.execution_policy_service import JsonLaunchRecorder
|
|
|
|
checked = validate_execution_context(context.model_dump(mode="json"))
|
|
if not isinstance(checked, JsonToolExecutionContext) or checked != context:
|
|
raise BuilderExecutionPolicyError("invalid_execution_context")
|
|
self._require_builder_owner(context)
|
|
recorder = self.execution_recorder
|
|
if not isinstance(recorder, JsonLaunchRecorder):
|
|
raise BuilderExecutionPolicyError("missing_json_tool_issuer")
|
|
snapshot = self._admission_snapshot()
|
|
for node_config in snapshot.graph.get("nodes", []):
|
|
self._validate_json_raw_config(node_config)
|
|
if admit_json_workflow(snapshot).nodes != context.admitted_nodes:
|
|
raise BuilderExecutionPolicyError("node_binding_mismatch")
|
|
native_run_id = get_system_text(self.runtime_state.variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID)
|
|
if not native_run_id:
|
|
raise BuilderExecutionPolicyError("execution_launch_mismatch")
|
|
self._json_tool_runtime = QualifiedJsonToolNodeRuntime(
|
|
self._dify_context,
|
|
native_run_id=native_run_id,
|
|
native_task_id=recorder.native_task_id,
|
|
issuer=recorder,
|
|
)
|
|
|
|
@override
|
|
def with_runtime_state(self, runtime_state: "RuntimeState") -> "DifyNodeFactory":
|
|
return DifyNodeFactory(
|
|
init_params=self._builder_init_params
|
|
if self._dify_context.builder_execution is not None or self._native_runtime is not None
|
|
else self.init_params,
|
|
runtime_state=runtime_state,
|
|
human_input_run_context=self._human_input_run_context,
|
|
use_workflow_tool_containers=self._use_workflow_tool_containers,
|
|
workflow_tools=self.workflow_tools,
|
|
execution_driver=self._execution_driver,
|
|
execution_recorder=self.execution_recorder,
|
|
_request_hmac_key=(
|
|
self._request_hmac_key
|
|
if isinstance(self._dify_context.builder_execution, BuilderExecutionContext)
|
|
else None
|
|
),
|
|
native_runtime=self._native_runtime,
|
|
).with_graph_config(self.init_params.graph_config)
|
|
|
|
@override
|
|
def with_graph_config(self, graph_config: Mapping[str, Any]) -> "DifyNodeFactory":
|
|
factory = copy(self)
|
|
factory.init_params = self.init_params.model_copy(update={"graph_config": graph_config})
|
|
return factory
|
|
|
|
@property
|
|
def execution_driver(self) -> "WorkflowRunDriver | None":
|
|
return self._execution_driver
|
|
|
|
@property
|
|
def human_input_run_context(self) -> DifyRunContext:
|
|
return self._human_input_run_context
|
|
|
|
@staticmethod
|
|
def _resolve_dify_context(run_context: Mapping[str, Any]) -> DifyRunContext:
|
|
raw_ctx = run_context.get(DIFY_RUN_CONTEXT_KEY)
|
|
if raw_ctx is None:
|
|
raise ValueError(f"run_context missing required key: {DIFY_RUN_CONTEXT_KEY}")
|
|
if isinstance(raw_ctx, DifyRunContext):
|
|
return raw_ctx
|
|
return DifyRunContext.model_validate(raw_ctx)
|
|
|
|
def _conversation_id(self) -> str | None:
|
|
return get_system_text(self.runtime_state.variable_pool, SystemVariableKey.CONVERSATION_ID)
|
|
|
|
def _raw_builder_node_config(self, node_config: Mapping[str, Any]) -> dict[str, Any]:
|
|
"""Remove only Graphon's generated root ownership field before checking the original binding."""
|
|
data = node_config.get("data")
|
|
if self._dify_context.builder_execution is None or not isinstance(data, BaseNodeData):
|
|
return dict(node_config)
|
|
raw_data = data.model_dump(mode="json", by_alias=True, exclude_unset=True)
|
|
original = next(
|
|
(node for node in self._builder_init_params.graph_config["nodes"] if node["id"] == node_config["id"]),
|
|
None,
|
|
)
|
|
if original is None:
|
|
raise BuilderExecutionPolicyError("execution_binding_mismatch")
|
|
if "container_id" not in original["data"] and raw_data.get("container_id") == "":
|
|
del raw_data["container_id"]
|
|
return {**node_config, "data": raw_data}
|
|
|
|
@override
|
|
def create_node(self, node_config: dict[str, Any] | NodeConfigDict) -> Node:
|
|
"""
|
|
Create a Node instance from node configuration data using the traditional mapping.
|
|
|
|
:param node_config: node configuration dictionary containing type and other data
|
|
:return: initialized Node instance
|
|
:raises ValueError: if node_config fails NodeConfigDict/BaseNodeData validation
|
|
(including pydantic ValidationError, which subclasses ValueError),
|
|
if node type is unknown, or if no implementation exists for the resolved version
|
|
"""
|
|
context = self._dify_context.builder_execution
|
|
node_config = self._raw_builder_node_config(node_config)
|
|
if isinstance(context, ConfirmedToolExecutionContext):
|
|
return self._create_confirmed_node(node_config)
|
|
if isinstance(context, JsonToolExecutionContext):
|
|
return self._create_json_node(node_config)
|
|
if context is not None:
|
|
self._validate_restricted_raw_config(node_config)
|
|
adapted_node_config = adapt_node_config_for_graph(node_config)
|
|
node_id, node_data, node_class, resolved_node_data = _validated_node_config(
|
|
adapted_node_config,
|
|
resolve_node_class=self._resolve_node_class_for_factory,
|
|
validate_resolved_node_data=self._validate_resolved_node_data,
|
|
pre_validate_node_data=self._validate_restricted_raw_config if context is not None else None,
|
|
)
|
|
if self._native_runtime is not None:
|
|
ref = self._dify_context.native_test_ref
|
|
if ref is None:
|
|
raise NativeContextUnavailable("native_context_missing")
|
|
prepared = self._native_runtime.prepare_node(
|
|
ref=ref,
|
|
node_id=node_id,
|
|
node_data=resolved_node_data,
|
|
node_class=node_class,
|
|
runtime_state=self.runtime_state,
|
|
)
|
|
node = prepared.node_class(
|
|
node_id=node_id,
|
|
data=resolved_node_data.model_dump(mode="python", by_alias=True),
|
|
init_params=self.init_params,
|
|
runtime_state=self.runtime_state,
|
|
**prepared.arguments,
|
|
)
|
|
prepared.attach(node)
|
|
return node
|
|
if isinstance(context, BuilderExecutionContext):
|
|
validate_restricted_node(
|
|
context=context, node_id=node_id, node_class=node_class, resolved_node_data=resolved_node_data
|
|
)
|
|
kwargs: dict[str, object] = {}
|
|
if node_data.type == BuiltinNodeTypes.HTTP_REQUEST:
|
|
assert self.execution_recorder is not None
|
|
client = RestrictedHttpClient(
|
|
context=context,
|
|
node_id=node_id,
|
|
recorder=self.execution_recorder,
|
|
request_hmac_key=self._request_hmac_key,
|
|
)
|
|
files = DeniedHttpFiles(client)
|
|
kwargs = {
|
|
"http_request_config": RESTRICTED_HTTP_CONFIG,
|
|
"http_client": client,
|
|
"tool_file_manager_factory": files,
|
|
"file_manager": files,
|
|
"file_reference_factory": files,
|
|
}
|
|
elif node_data.type == BuiltinNodeTypes.TEMPLATE_TRANSFORM:
|
|
kwargs = {
|
|
"jinja2_template_renderer": BoundedJinja2TemplateRenderer(),
|
|
"max_output_length": MAX_OUTPUT_BYTES,
|
|
}
|
|
return node_class(
|
|
node_id=node_id,
|
|
data=resolved_node_data.model_dump(mode="python", by_alias=True),
|
|
init_params=self.init_params,
|
|
runtime_state=self.runtime_state,
|
|
**kwargs,
|
|
)
|
|
if node_id in self._dify_context.builder_mocked_node_ids:
|
|
# Validate native configuration, then stop before any dependency
|
|
# factory or native constructor can acquire execution resources.
|
|
return mocked_node_class(node_class)(
|
|
node_id=node_id,
|
|
data=resolved_node_data.model_dump(mode="python", by_alias=True),
|
|
init_params=self.init_params,
|
|
runtime_state=self.runtime_state,
|
|
)
|
|
node_type = node_data.type
|
|
if node_type == BuiltinNodeTypes.LLM:
|
|
resolved_node_data = self._resolve_llm_model_reference(cast(LLMNodeData, resolved_node_data))
|
|
node: Node | None = None
|
|
node_init_kwargs_factories: Mapping[NodeType, Callable[[], dict[str, object]]] = {
|
|
BuiltinNodeTypes.DATASOURCE: lambda: {
|
|
"datasource_credentials": build_data_source_credentials(
|
|
database_client=get_session_maker()
|
|
).providers.get_datasource_credentials,
|
|
},
|
|
BuiltinNodeTypes.CODE: lambda: {
|
|
"code_executor": self._code_executor,
|
|
"code_limits": self._code_limits,
|
|
},
|
|
BuiltinNodeTypes.TEMPLATE_TRANSFORM: lambda: {
|
|
"jinja2_template_renderer": self._jinja2_template_renderer,
|
|
"max_output_length": self._template_transform_max_output_length,
|
|
},
|
|
BuiltinNodeTypes.HTTP_REQUEST: lambda: {
|
|
"http_request_config": self._http_request_config,
|
|
"http_client": self._http_request_http_client,
|
|
"tool_file_manager_factory": self._bound_tool_file_manager_factory,
|
|
"file_manager": self._http_request_file_manager,
|
|
"file_reference_factory": self._file_reference_factory,
|
|
},
|
|
BuiltinNodeTypes.HUMAN_INPUT: lambda: (
|
|
{
|
|
"human_input_runtime": WorkflowHumanInputRuntime(self._dify_context),
|
|
}
|
|
if node_class is HumanInputV2Node
|
|
else {
|
|
"hitl_callback": self._build_human_input_callback(
|
|
node_data=DifyHumanInputNodeData.model_validate(adapted_node_config["data"]),
|
|
execution_id_getter=lambda: node.execution_id if node is not None else None,
|
|
),
|
|
}
|
|
),
|
|
BuiltinNodeTypes.LLM: lambda: self._build_llm_compatible_node_init_kwargs(
|
|
node_class=node_class,
|
|
node_data=resolved_node_data,
|
|
wrap_model_instance=True,
|
|
include_http_client=True,
|
|
include_llm_file_saver=True,
|
|
include_prompt_message_serializer=True,
|
|
include_retriever_attachment_loader=True,
|
|
include_jinja2_template_renderer=True,
|
|
),
|
|
BuiltinNodeTypes.DOCUMENT_EXTRACTOR: lambda: {
|
|
"unstructured_api_config": self._document_extractor_unstructured_api_config,
|
|
"http_client": self._remote_file_http_client,
|
|
},
|
|
BuiltinNodeTypes.QUESTION_CLASSIFIER: lambda: self._build_llm_compatible_node_init_kwargs(
|
|
node_class=node_class,
|
|
node_data=resolved_node_data,
|
|
wrap_model_instance=True,
|
|
include_http_client=True,
|
|
include_llm_file_saver=True,
|
|
include_prompt_message_serializer=True,
|
|
include_retriever_attachment_loader=False,
|
|
include_jinja2_template_renderer=False,
|
|
),
|
|
BuiltinNodeTypes.PARAMETER_EXTRACTOR: lambda: self._build_llm_compatible_node_init_kwargs(
|
|
node_class=node_class,
|
|
node_data=resolved_node_data,
|
|
wrap_model_instance=True,
|
|
include_http_client=False,
|
|
include_llm_file_saver=False,
|
|
include_prompt_message_serializer=True,
|
|
include_retriever_attachment_loader=False,
|
|
include_jinja2_template_renderer=False,
|
|
),
|
|
BuiltinNodeTypes.TOOL: lambda: {
|
|
"tool_file_manager": self._bound_tool_file_manager_factory(),
|
|
"runtime": self._tool_runtime,
|
|
},
|
|
BuiltinNodeTypes.AGENT: lambda: self._build_agent_node_init_kwargs(node_class=node_class),
|
|
KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE: lambda: {
|
|
**build_knowledge_fs_node_dependencies(),
|
|
"metadata_filter_extractor": KnowledgeFSAutomaticMetadataFilterExtractor(
|
|
credentials_provider=self._llm_credentials_provider,
|
|
model_factory=self._llm_model_factory,
|
|
),
|
|
},
|
|
}
|
|
node_init_kwargs = node_init_kwargs_factories.get(node_type, lambda: {})()
|
|
constructor_node_data = resolved_node_data.model_dump(mode="python", by_alias=True)
|
|
node = node_class(
|
|
node_id=node_id,
|
|
data=constructor_node_data,
|
|
init_params=self.init_params,
|
|
runtime_state=self.runtime_state,
|
|
**node_init_kwargs,
|
|
)
|
|
return node
|
|
|
|
def _create_confirmed_node(self, node_config: Mapping[str, Any]) -> Node:
|
|
"""Only the exact admitted Start/Tool v1/End classes; the Tool gets the confirmed runtime only."""
|
|
self._validate_confirmed_raw_config(node_config)
|
|
adapted_node_config = adapt_node_config_for_graph(node_config)
|
|
node_id, _node_data, node_class, resolved_node_data = _validated_node_config(
|
|
adapted_node_config,
|
|
resolve_node_class=self._resolve_node_class_for_factory,
|
|
validate_resolved_node_data=self._validate_resolved_node_data,
|
|
pre_validate_node_data=self._validate_confirmed_raw_config,
|
|
)
|
|
if node_class is not _CONFIRMED_CLASSES.get(resolved_node_data.type) or resolved_node_data.version != "1":
|
|
raise BuilderExecutionPolicyError("unsupported_node_implementation")
|
|
kwargs: dict[str, object] = {}
|
|
if node_class is ToolNode:
|
|
kwargs = {"tool_file_manager": _DeniedConfirmedToolFiles(), "runtime": self._confirmed_tool_runtime}
|
|
return node_class(
|
|
node_id=node_id,
|
|
data=resolved_node_data.model_dump(mode="python", by_alias=True),
|
|
init_params=self.init_params,
|
|
runtime_state=self.runtime_state,
|
|
**kwargs,
|
|
)
|
|
|
|
def _create_json_node(self, node_config: Mapping[str, Any]) -> Node:
|
|
"""Construct only audited native classes, with the qualified Tool runtime."""
|
|
self._validate_json_raw_config(node_config)
|
|
adapted = adapt_node_config_for_graph(node_config)
|
|
node_id, _node_data, node_class, resolved = _validated_node_config(
|
|
adapted,
|
|
resolve_node_class=self._resolve_node_class_for_factory,
|
|
validate_resolved_node_data=self._validate_resolved_node_data,
|
|
)
|
|
if node_class is not _CONFIRMED_CLASSES.get(resolved.type) or resolved.version != "1":
|
|
raise BuilderExecutionPolicyError("unsupported_node_implementation")
|
|
kwargs: dict[str, object] = {}
|
|
if node_class is ToolNode:
|
|
kwargs = {"tool_file_manager": _DeniedConfirmedToolFiles(), "runtime": self._json_tool_runtime}
|
|
return node_class(
|
|
node_id=node_id,
|
|
data=resolved.model_dump(mode="python", by_alias=True),
|
|
init_params=self.init_params,
|
|
runtime_state=self.runtime_state,
|
|
**kwargs,
|
|
)
|
|
|
|
def _validate_json_raw_config(self, node_config: Mapping[str, Any], node_class: type[Node] | None = None) -> None:
|
|
context = self._dify_context.builder_execution
|
|
if not isinstance(context, JsonToolExecutionContext):
|
|
raise BuilderExecutionPolicyError("unsupported_execution_policy")
|
|
data, node_id = node_config.get("data"), node_config.get("id")
|
|
if not isinstance(data, Mapping) or not isinstance(node_id, str):
|
|
raise BuilderExecutionPolicyError("invalid_node_configuration")
|
|
node_type, version = data.get("type"), data.get("version", "1")
|
|
if not isinstance(node_type, str) or node_type not in _CONFIRMED_CLASSES or version != "1":
|
|
raise BuilderExecutionPolicyError("unsupported_node_implementation")
|
|
if node_class is None:
|
|
node_class = self._resolve_node_class(node_type=node_type, node_version=version, node_data=data)
|
|
if node_class is not _CONFIRMED_CLASSES[node_type] or node_class.version() != "1":
|
|
raise BuilderExecutionPolicyError("unsupported_node_implementation")
|
|
binding = next((item for item in context.admitted_nodes if item.node_id == node_id), None)
|
|
if (
|
|
binding is None
|
|
or binding.implementation != f"{node_class.__module__}.{node_class.__qualname__}"
|
|
or binding.node_version != "1"
|
|
or binding.normalized_config_digest != node_config_digest(data)
|
|
):
|
|
raise BuilderExecutionPolicyError("execution_binding_mismatch")
|
|
|
|
def _validate_confirmed_raw_config(
|
|
self, node_config: Mapping[str, Any], node_class: type[Node] | None = None
|
|
) -> None:
|
|
context = self._dify_context.builder_execution
|
|
if not isinstance(context, ConfirmedToolExecutionContext):
|
|
raise BuilderExecutionPolicyError("unsupported_execution_policy")
|
|
data, node_id = node_config.get("data"), node_config.get("id")
|
|
if not isinstance(data, Mapping) or not isinstance(node_id, str):
|
|
raise BuilderExecutionPolicyError("invalid_node_configuration")
|
|
node_type, version = data.get("type"), data.get("version", "1")
|
|
if not isinstance(node_type, str) or node_type not in _CONFIRMED_CLASSES or version != "1":
|
|
raise BuilderExecutionPolicyError("unsupported_node_implementation")
|
|
if node_class is None:
|
|
node_class = self._resolve_node_class(node_type=node_type, node_version=version, node_data=data)
|
|
if node_class is not _CONFIRMED_CLASSES[node_type] or node_class.version() != "1":
|
|
raise BuilderExecutionPolicyError("unsupported_node_implementation")
|
|
binding = next((b for b in context.admitted_nodes if b.node_id == node_id), None)
|
|
if (
|
|
binding is None
|
|
or binding.implementation != f"{node_class.__module__}.{node_class.__qualname__}"
|
|
or binding.node_version != "1"
|
|
or binding.normalized_config_digest != node_config_digest(data)
|
|
):
|
|
raise BuilderExecutionPolicyError("execution_binding_mismatch")
|
|
|
|
def _validate_restricted_raw_config(
|
|
self, node_config: Mapping[str, Any], node_class: type[Node] | None = None
|
|
) -> None:
|
|
context = self._dify_context.builder_execution
|
|
assert isinstance(context, BuilderExecutionContext)
|
|
data, node_id = node_config.get("data"), node_config.get("id")
|
|
if not isinstance(data, Mapping) or not isinstance(node_id, str):
|
|
raise BuilderExecutionPolicyError("invalid_node_configuration")
|
|
node_type, version = data.get("type"), data.get("version", "1")
|
|
if (
|
|
not isinstance(node_type, str)
|
|
or node_type not in {"start", "end", "http-request", "template-transform"}
|
|
or version != "1"
|
|
):
|
|
raise BuilderExecutionPolicyError("unsupported_node_implementation")
|
|
if node_class is None:
|
|
node_class = self._resolve_node_class(node_type=node_type, node_version=version, node_data=data)
|
|
validate_restricted_raw_node(context=context, node_id=node_id, node_class=node_class, raw_node_data=data)
|
|
|
|
@override
|
|
def validate_node(self, node_config: NodeConfigDict) -> NodeExecutionType:
|
|
context = self._dify_context.builder_execution
|
|
raw_node_config = self._raw_builder_node_config(node_config)
|
|
validator = None
|
|
if isinstance(context, ConfirmedToolExecutionContext):
|
|
validator = self._validate_confirmed_raw_config
|
|
elif isinstance(context, JsonToolExecutionContext):
|
|
validator = self._validate_json_raw_config
|
|
elif isinstance(context, BuilderExecutionContext):
|
|
validator = self._validate_restricted_raw_config
|
|
if validator is not None:
|
|
validator(raw_node_config, None)
|
|
node_id, _, node_class, resolved_node_data = _validated_node_config(
|
|
adapt_node_config_for_graph(raw_node_config),
|
|
resolve_node_class=self._resolve_node_class_for_factory,
|
|
validate_resolved_node_data=self._validate_resolved_node_data,
|
|
# Compatibility adaptation can move wrapped Tool configurations into
|
|
# parameters. Bind the saved raw configuration to the actual resolved
|
|
# implementation, rather than comparing that adapted representation
|
|
# with the raw digest admitted before graph initialization.
|
|
pre_validate_node_data=(
|
|
(lambda _adapted_config, resolved_class: validator(raw_node_config, resolved_class))
|
|
if validator is not None
|
|
else None
|
|
),
|
|
)
|
|
if isinstance(context, BuilderExecutionContext):
|
|
validate_restricted_node(
|
|
context=context, node_id=node_id, node_class=node_class, resolved_node_data=resolved_node_data
|
|
)
|
|
return node_class.execution_type
|
|
|
|
@staticmethod
|
|
def _validate_resolved_node_data(node_class: type[Node], node_data: BaseNodeData) -> BaseNodeData:
|
|
"""
|
|
Re-validate the permissive graph payload with the concrete NodeData model declared by the resolved node class.
|
|
"""
|
|
validate_node_data = getattr(node_class, "validate_node_data", None)
|
|
if callable(validate_node_data):
|
|
return cast("BaseNodeData", validate_node_data(node_data))
|
|
return node_data
|
|
|
|
def _resolve_node_class_for_factory(
|
|
self,
|
|
*,
|
|
node_type: NodeType,
|
|
node_version: str,
|
|
node_data: BaseNodeData,
|
|
) -> type[Node]:
|
|
if (
|
|
self._use_workflow_tool_containers
|
|
and self._dify_context.builder_execution is None
|
|
and self._native_runtime is None
|
|
and node_type == BuiltinNodeTypes.TOOL
|
|
and node_data.model_dump().get("provider_type") == ToolProviderType.WORKFLOW
|
|
):
|
|
return DifyWorkflowToolNode
|
|
return self._resolve_node_class(
|
|
node_type=node_type,
|
|
node_version=node_version,
|
|
node_data=node_data,
|
|
)
|
|
|
|
@staticmethod
|
|
def _resolve_node_class(
|
|
*,
|
|
node_type: NodeType,
|
|
node_version: str,
|
|
node_data: Mapping[str, Any] | BaseNodeData | None = None,
|
|
) -> type[Node]:
|
|
if node_type == BuiltinNodeTypes.LLM:
|
|
return DifyLLMNode
|
|
return resolve_workflow_node_class(
|
|
node_type=node_type,
|
|
node_version=node_version,
|
|
node_data=node_data,
|
|
)
|
|
|
|
def _resolve_llm_model_reference(self, node_data: LLMNodeData) -> LLMNodeData:
|
|
"""Resolve an optional shared model selector from the workflow variable pool."""
|
|
|
|
model_selector = (node_data.model_extra or {}).get("model_selector")
|
|
if not should_resolve_llm_model_selector(model_selector):
|
|
return node_data
|
|
|
|
selector = parse_llm_model_selector(model_selector)
|
|
variable = self.runtime_state.variable_pool.get(selector)
|
|
if not isinstance(variable, ObjectSegment):
|
|
raise ValueError(f"LLM environment variable '{selector[1]}' was not found or is not an LLM variable")
|
|
|
|
resolved_model = resolve_llm_model_config(
|
|
node_model=node_data.model,
|
|
variable_name=selector[1],
|
|
variable_value=variable.value,
|
|
)
|
|
return node_data.model_copy(update={"model": resolved_model})
|
|
|
|
def _build_agent_node_init_kwargs(self, *, node_class: type[Node]) -> dict[str, object]:
|
|
if issubclass(node_class, DifyAgentNode):
|
|
from clients.agent_backend import AgentBackendRunEventAdapter, AgentBackendRunRequestBuilder
|
|
from clients.agent_backend.factory import create_agent_backend_run_client
|
|
from core.workflow.nodes.agent_v2.file_tenant_validator import AgentOutputFileTenantValidator
|
|
from core.workflow.nodes.agent_v2.output_failure_orchestrator import OutputFailureOrchestrator
|
|
from core.workflow.nodes.agent_v2.output_file_rebacker import reback_tool_file_output
|
|
from core.workflow.nodes.agent_v2.output_type_checker import PerOutputTypeChecker
|
|
from core.workflow.nodes.agent_v2.session_store import WorkflowAgentWorkspaceStore
|
|
from services.dify_builder.native_testing.agent_tool_declarations import AgentToolDeclarationService
|
|
|
|
kwargs: dict[str, object] = {
|
|
"binding_resolver": WorkflowAgentBindingResolver(),
|
|
"runtime_request_builder": WorkflowAgentRuntimeRequestBuilder(
|
|
request_builder=AgentBackendRunRequestBuilder(),
|
|
dify_tools_builder=WorkflowAgentDifyToolsBuilder(
|
|
native_declaration_preparer=AgentToolDeclarationService(),
|
|
),
|
|
),
|
|
"agent_backend_client": create_agent_backend_run_client(
|
|
base_url=dify_config.AGENT_BACKEND_BASE_URL,
|
|
api_token=dify_config.AGENT_BACKEND_API_TOKEN,
|
|
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
|
|
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
|
|
stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS,
|
|
stream_max_reconnects=dify_config.AGENT_BACKEND_STREAM_MAX_RECONNECTS,
|
|
),
|
|
"event_adapter": AgentBackendRunEventAdapter(),
|
|
# Agent Files §4.6: reback file outputs from the ToolFile row so
|
|
# downstream metadata is authoritative, not sandbox-provided.
|
|
"output_adapter": WorkflowAgentOutputAdapter(tool_file_rebacker=reback_tool_file_output),
|
|
# Stage 4 §5/§7: per-output validation + failure orchestration. The
|
|
# tenant validator resolves ToolFile (canonical) + UploadFile refs.
|
|
"type_checker": PerOutputTypeChecker(file_validator=AgentOutputFileTenantValidator()),
|
|
"failure_orchestrator": OutputFailureOrchestrator(),
|
|
"session_store": WorkflowAgentWorkspaceStore(),
|
|
}
|
|
if self._dify_context.builder_agent_execution is not None:
|
|
from core.dify_builder.models import Actor
|
|
from services.dify_builder.wiring import build_confirmed_agent_node_owners
|
|
|
|
preparation, commands, recovery = build_confirmed_agent_node_owners(
|
|
actor=Actor(tenant_id=self._dify_context.tenant_id, account_id=self._dify_context.user_id),
|
|
backend=kwargs["agent_backend_client"],
|
|
)
|
|
kwargs.update(agent_preparation=preparation, agent_commands=commands, agent_run_recovery=recovery)
|
|
return kwargs
|
|
return {
|
|
"strategy_resolver": self._agent_strategy_resolver,
|
|
"presentation_provider": self._agent_strategy_presentation_provider,
|
|
"runtime_support": self._agent_runtime_support,
|
|
"message_transformer": self._agent_message_transformer,
|
|
}
|
|
|
|
def _build_human_input_callback(
|
|
self,
|
|
*,
|
|
node_data: DifyHumanInputNodeData,
|
|
execution_id_getter: Callable[[], str | None],
|
|
) -> DifyHITLCallback:
|
|
return DifyHITLCallback(
|
|
form_repository=self._human_input_runtime.build_form_repository(),
|
|
node_data=node_data,
|
|
conversation_id=self._conversation_id(),
|
|
delivery_methods=self._human_input_runtime._resolve_delivery_methods(node_data=node_data),
|
|
display_in_ui=self._human_input_runtime._display_in_ui(node_data=node_data),
|
|
file_reference_factory=self._file_reference_factory,
|
|
execution_id_getter=execution_id_getter,
|
|
)
|
|
|
|
def _build_llm_compatible_node_init_kwargs(
|
|
self,
|
|
*,
|
|
node_class: type[Node],
|
|
node_data: BaseNodeData,
|
|
wrap_model_instance: bool,
|
|
include_http_client: bool,
|
|
include_llm_file_saver: bool,
|
|
include_prompt_message_serializer: bool,
|
|
include_retriever_attachment_loader: bool,
|
|
include_jinja2_template_renderer: bool,
|
|
) -> dict[str, object]:
|
|
validated_node_data = cast(LLMCompatibleNodeData, node_data)
|
|
model_instance = self._build_model_instance_for_llm_node(validated_node_data)
|
|
request_metadata: dict[str, object] = {"app_id": self._dify_context.app_id}
|
|
app_type = self._dify_context.app_type
|
|
created_by = self._dify_context.created_by
|
|
if app_type is not None:
|
|
request_metadata["app_type"] = app_type
|
|
request_metadata["created_by"] = created_by_from_app_type(app_type)
|
|
elif created_by is not None:
|
|
request_metadata["created_by"] = created_by
|
|
|
|
node_model_instance = (
|
|
self._wrap_model_instance_for_node(
|
|
node_data=validated_node_data,
|
|
model_instance=model_instance,
|
|
request_metadata=request_metadata,
|
|
)
|
|
if wrap_model_instance
|
|
else model_instance
|
|
)
|
|
node_init_kwargs: dict[str, object] = {
|
|
"credentials_provider": self._llm_credentials_provider,
|
|
"model_factory": self._llm_model_factory,
|
|
"model_instance": node_model_instance,
|
|
"memory": self._build_memory_for_llm_node(
|
|
node_data=validated_node_data,
|
|
model_instance=model_instance,
|
|
),
|
|
}
|
|
if validated_node_data.type == BuiltinNodeTypes.QUESTION_CLASSIFIER:
|
|
node_init_kwargs["template_renderer"] = self._jinja2_template_renderer
|
|
if include_http_client:
|
|
node_init_kwargs["http_client"] = self._remote_file_http_client
|
|
if include_llm_file_saver:
|
|
node_init_kwargs["llm_file_saver"] = self._llm_file_saver
|
|
if include_prompt_message_serializer:
|
|
node_init_kwargs["prompt_message_serializer"] = self._prompt_message_serializer
|
|
if include_retriever_attachment_loader:
|
|
node_init_kwargs["retriever_attachment_loader"] = self._build_retriever_attachment_loader(
|
|
cast(LLMNodeData, validated_node_data)
|
|
)
|
|
if include_jinja2_template_renderer:
|
|
node_init_kwargs["jinja2_template_renderer"] = self._jinja2_template_renderer
|
|
if validated_node_data.type == BuiltinNodeTypes.LLM:
|
|
node_init_kwargs["default_query_selector"] = system_variable_selector(SystemVariableKey.QUERY)
|
|
node_init_kwargs["polling_finalizer"] = cast(DifyPreparedLLM, node_model_instance).finalize_llm_polling
|
|
return node_init_kwargs
|
|
|
|
@staticmethod
|
|
def _wrap_model_instance_for_node(
|
|
*,
|
|
node_data: LLMCompatibleNodeData,
|
|
model_instance: ModelInstance,
|
|
request_metadata: Mapping[str, object] | None = None,
|
|
) -> DifyPreparedLLM:
|
|
# Only graphon's LLM node consumes the polling protocol. Keep classifier
|
|
# and extractor nodes on the existing wrapper even if the same model
|
|
# advertises polling support.
|
|
if node_data.type == BuiltinNodeTypes.LLM and DifyNodeFactory._supports_plugin_llm_polling(model_instance):
|
|
return DifyPreparedPollingLLM(model_instance, request_metadata=request_metadata)
|
|
return DifyPreparedLLM(model_instance, request_metadata=request_metadata)
|
|
|
|
@staticmethod
|
|
def _supports_plugin_llm_polling(model_instance: ModelInstance) -> bool:
|
|
return model_instance.get_model_schema().support_polling
|
|
|
|
def _build_retriever_attachment_loader(self, node_data: LLMNodeData) -> DifyRetrieverAttachmentLoader:
|
|
return DifyRetrieverAttachmentLoader(
|
|
file_reference_factory=self._file_reference_factory,
|
|
segment_access_checker=self._build_retriever_segment_access_checker(
|
|
node_data.context.variable_selector if node_data.context.enabled else None
|
|
),
|
|
)
|
|
|
|
def _build_retriever_segment_access_checker(
|
|
self,
|
|
context_variable_selector: Sequence[str] | None,
|
|
) -> Callable[[str], bool]:
|
|
def checker(segment_id: str) -> bool:
|
|
if not context_variable_selector:
|
|
return False
|
|
|
|
context_value = self.runtime_state.variable_pool.get(context_variable_selector)
|
|
if not isinstance(context_value, ArrayObjectSegment):
|
|
return False
|
|
|
|
for item in context_value.value:
|
|
if not isinstance(item, Mapping):
|
|
continue
|
|
metadata = item.get("metadata")
|
|
if not isinstance(metadata, Mapping):
|
|
continue
|
|
if metadata.get("_source") == "knowledge" and str(metadata.get("segment_id")) == str(segment_id):
|
|
return True
|
|
return False
|
|
|
|
return checker
|
|
|
|
def _build_model_instance_for_llm_node(self, node_data: LLMCompatibleNodeData) -> ModelInstance:
|
|
node_data_model = node_data.model
|
|
model_instance, _ = fetch_model_config(
|
|
node_data_model=node_data_model,
|
|
credentials_provider=self._llm_credentials_provider,
|
|
model_factory=self._llm_model_factory,
|
|
)
|
|
model_instance.model_type_instance = cast(LargeLanguageModel, model_instance.model_type_instance)
|
|
return model_instance
|
|
|
|
def _build_memory_for_llm_node(
|
|
self,
|
|
*,
|
|
node_data: LLMCompatibleNodeData,
|
|
model_instance: ModelInstance,
|
|
) -> PromptMessageMemory | None:
|
|
if node_data.memory is None:
|
|
return None
|
|
|
|
conversation_id = get_system_text(self.runtime_state.variable_pool, SystemVariableKey.CONVERSATION_ID)
|
|
return fetch_memory(
|
|
conversation_id=conversation_id,
|
|
app_id=self._dify_context.app_id,
|
|
node_data_memory=node_data.memory,
|
|
model_instance=model_instance,
|
|
)
|
|
|
|
|
|
def _node_config_error(node_config: Mapping[str, Any], exc: Exception) -> ValueError:
|
|
"""``exc`` re-raised as a ``ValueError`` whose message starts with the
|
|
offending node's id and type, e.g. ``node 'node4' (http-request): ...``."""
|
|
data = node_config.get("data")
|
|
node_type = str(data.get("type") or "") if isinstance(data, Mapping) else ""
|
|
node_id = str(node_config.get("id") or "")
|
|
return ValueError(f"node {node_id!r} ({node_type or 'unknown type'}): {exc}")
|
|
|
|
|
|
def _validated_node_config(
|
|
adapted_node_config: Mapping[str, Any],
|
|
*,
|
|
resolve_node_class: Callable[..., type[Node]] = DifyNodeFactory._resolve_node_class,
|
|
validate_resolved_node_data: Callable[[type[Node], BaseNodeData], BaseNodeData] = (
|
|
DifyNodeFactory._validate_resolved_node_data
|
|
),
|
|
pre_validate_node_data: Callable[[Mapping[str, Any], type[Node] | None], None] | None = None,
|
|
) -> tuple[str, BaseNodeData, type[Node], BaseNodeData]:
|
|
"""Adapt-shaped node config in, fully validated node out -- the single
|
|
sequence ``DifyNodeFactory.create_node`` and ``validate_node_config`` both
|
|
run: shared-shape validation, node-class resolution, then re-validation
|
|
against the concrete NodeData model the resolved class declares, then
|
|
Human Input delivery-method validation through the runtime's pure parser.
|
|
|
|
``validate_node_config``'s whole contract is "whatever raises here would
|
|
raise at ``Graph.init``", which only holds while the two callers share this
|
|
one sequence. ``create_node`` passes its own bound ``_resolve_node_class`` /
|
|
``_validate_resolved_node_data`` (both ``@staticmethod``s in production, but
|
|
tests monkeypatch them per-instance); ``validate_node_config`` has no
|
|
instance, so it defaults to the class's own.
|
|
|
|
Restricted callers supply a callback to check raw bindings before common DTO
|
|
validation and the actual selected class immediately before concrete validation.
|
|
Its typed policy refusal is preserved without generic node error wrapping.
|
|
|
|
Returns ``(node_id, shared node_data, resolved node_class, resolved_node_data)``.
|
|
Ordinary validation raises ``ValueError`` prefixed ``node '<id>' (<type>): ``.
|
|
"""
|
|
if pre_validate_node_data is not None:
|
|
pre_validate_node_data(adapted_node_config, None)
|
|
try:
|
|
typed_node_config = NodeConfigDictAdapter.validate_python(adapted_node_config)
|
|
node_id = typed_node_config["id"]
|
|
node_data = typed_node_config["data"]
|
|
node_class = resolve_node_class(
|
|
node_type=node_data.type,
|
|
node_version=str(node_data.version),
|
|
node_data=node_data,
|
|
)
|
|
if pre_validate_node_data is not None:
|
|
# Guard the actual selected class too: the registry may change between lookups.
|
|
pre_validate_node_data(adapted_node_config, node_class)
|
|
# Graph configs are initially validated against permissive shared node data.
|
|
# Re-validate using the resolved node class so workflow-local node schemas
|
|
# stay explicit and constructors receive the concrete typed payload.
|
|
resolved_node_data = validate_resolved_node_data(node_class, node_data)
|
|
if node_data.type == BuiltinNodeTypes.HUMAN_INPUT:
|
|
parse_human_input_delivery_methods(resolved_node_data)
|
|
except BuilderExecutionPolicyError:
|
|
# Preserve the typed, sanitized policy refusal for native routing.
|
|
raise
|
|
except ValueError as exc:
|
|
# pydantic's ValidationError subclasses ValueError. Its message names
|
|
# only the model class ("... for HttpRequestNodeData"), so a run that
|
|
# dies at Graph.init could not say WHICH node was wrong (ESQ1-302).
|
|
# Every workflow start goes through here, so this changes the error
|
|
# text of every graph-init failure, not only the Builder's.
|
|
raise _node_config_error(adapted_node_config, exc) from exc
|
|
return node_id, node_data, node_class, resolved_node_data
|
|
|
|
|
|
def validate_node_config(node_config: Mapping[str, Any] | NodeConfigDict) -> BaseNodeData:
|
|
"""Validate ONE raw node config exactly as ``DifyNodeFactory.create_node``
|
|
does before constructing the node -- adapt, shared-shape validation,
|
|
node-class resolution, concrete NodeData re-validation, Human Input delivery
|
|
parsing -- with no factory instance and no runtime state.
|
|
|
|
This is the dry check a draft can be put through BEFORE it is run: whatever
|
|
raises here would raise at ``Graph.init`` and kill the run before its first
|
|
node. Raises ``ValueError`` prefixed ``node <id> (<type>):``.
|
|
"""
|
|
adapted_node_config = adapt_node_config_for_graph(node_config)
|
|
_, _, _, resolved_node_data = _validated_node_config(adapted_node_config)
|
|
return resolved_node_data
|