From eb87866468bcdb0936d8484c7706b7ce36bd86e3 Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Thu, 8 Oct 2026 18:35:49 +0800 Subject: [PATCH 1/7] refactor(api): remove superseded Workflow node implementations --- api/.importlinter | 8 - api/core/workflow/node_factory.py | 747 ----------- api/core/workflow/node_runtime.py | 1001 -------------- api/core/workflow/nodes/agent/agent_node.py | 222 ---- .../workflow/nodes/agent/runtime_support.py | 320 ----- .../workflow/nodes/agent_v2/agent_node.py | 876 ------------- .../nodes/agent_v2/binding_resolver.py | 151 --- .../nodes/agent_v2/dify_tools_builder.py | 570 -------- .../nodes/agent_v2/runtime_request_builder.py | 1158 ----------------- .../workflow/nodes/agent_v2/validators.py | 660 ---------- .../workflow/nodes/human_input/boundary.py | 138 -- .../workflow/nodes/human_input/callback.py | 321 ----- .../knowledge_retrieval_node.py | 364 ------ .../workflow/nodes/trigger_webhook/node.py | 183 --- api/core/workflow/workflow_entry.py | 605 --------- .../adapters/agent_message_transformer.py | 341 ----- .../execution/adapters/node_factory.py | 19 +- 17 files changed, 1 insertion(+), 7683 deletions(-) delete mode 100644 api/core/workflow/node_factory.py delete mode 100644 api/core/workflow/node_runtime.py delete mode 100644 api/core/workflow/nodes/agent/agent_node.py delete mode 100644 api/core/workflow/nodes/agent/runtime_support.py delete mode 100644 api/core/workflow/nodes/agent_v2/agent_node.py delete mode 100644 api/core/workflow/nodes/agent_v2/binding_resolver.py delete mode 100644 api/core/workflow/nodes/agent_v2/dify_tools_builder.py delete mode 100644 api/core/workflow/nodes/agent_v2/runtime_request_builder.py delete mode 100644 api/core/workflow/nodes/agent_v2/validators.py delete mode 100644 api/core/workflow/nodes/human_input/boundary.py delete mode 100644 api/core/workflow/nodes/human_input/callback.py delete mode 100644 api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py delete mode 100644 api/core/workflow/nodes/trigger_webhook/node.py delete mode 100644 api/core/workflow/workflow_entry.py delete mode 100644 api/services/workflow/execution/adapters/agent_message_transformer.py diff --git a/api/.importlinter b/api/.importlinter index e8bc504be34049..c68198905d4022 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -88,17 +88,9 @@ ignore_imports = core.tools.tool_manager -> services.tools.legacy_builtin_tools_manage_service core.tools.tool_manager -> services.tools.legacy_mcp_tools_manage_service core.tools.tool_manager -> services.tools.legacy_tools_transform_service - core.workflow.node_runtime -> services.tools.legacy_builtin_tools_manage_service # Node construction delegates datasource dependency assembly to the composition root. - core.workflow.node_factory -> extensions.application_services.data_sources - core.workflow.nodes.agent_v2.agent_node -> services.agent.prompt_mentions - core.workflow.nodes.agent_v2.agent_node -> services.agent.workspace_service - core.workflow.nodes.agent_v2.dify_tools_builder -> services.tools.legacy_mcp_tools_manage_service core.workflow.nodes.agent_v2.runtime_feature_manifest -> services.agent.knowledge_datasets - core.workflow.nodes.agent_v2.runtime_request_builder -> services.agent.prompt_mentions - core.workflow.nodes.agent_v2.runtime_request_builder -> services.skill_management_service core.workflow.nodes.agent_v2.session_store -> services.agent.workspace_service - core.workflow.nodes.agent_v2.validators -> services.agent.knowledge_datasets core.workflow.nodes.agent_v2.workspace_retirement_layer -> tasks.collect_agent_resources_task libs.email_i18n -> services.entities.feature_entities libs.email_i18n -> services.system_feature_service diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py deleted file mode 100644 index b4a5f5eb395bc5..00000000000000 --- a/api/core/workflow/node_factory.py +++ /dev/null @@ -1,747 +0,0 @@ -import importlib -import pkgutil -from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence -from dataclasses import dataclass -from functools import lru_cache -from typing import TYPE_CHECKING, Any, cast, final, override - -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.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 -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, -) -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 import DifyAgentNode -from core.workflow.nodes.agent_v2.binding_resolver import WorkflowAgentBindingResolver -from core.workflow.nodes.agent_v2.discriminator import is_dify_agent_node_data -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.system_variables import SystemVariableKey, get_system_text, system_variable_selector -from core.workflow.template_rendering import CodeExecutorJinja2TemplateRenderer -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, 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.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.variables.segments import ArrayObjectSegment, ObjectSegment -from models.human_input_entities import HumanInputNodeData as DifyHumanInputNodeData -from models.model import Conversation - -if TYPE_CHECKING: - from graphon.entities import GraphInitParams - from graphon.runtime import GraphRuntimeState - -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 `GraphInitParams` 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 - - def to_graph_init_params(self) -> "GraphInitParams": - from graphon.entities import GraphInitParams - - return GraphInitParams( - 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") - - -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) - - -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): - """ - Default implementation of NodeFactory that resolves node classes from the live registry. - """ - - @classmethod - def from_graph_init_context( - cls, - *, - graph_init_context: DifyGraphInitContext, - graph_runtime_state: "GraphRuntimeState", - ) -> "DifyNodeFactory": - """Bridge Dify's explicit init context into the current `graphon` API.""" - return cls( - graph_init_params=graph_init_context.to_graph_init_params(), - graph_runtime_state=graph_runtime_state, - ) - - def __init__( - self, - graph_init_params: "GraphInitParams", - graph_runtime_state: "GraphRuntimeState", - ) -> None: - self.graph_init_params = graph_init_params - self.graph_runtime_state = graph_runtime_state - self._dify_context = self._resolve_dify_context(graph_init_params.run_context) - 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._dify_context, - workflow_execution_id_getter=lambda: get_system_text( - self.graph_runtime_state.variable_pool, - SystemVariableKey.WORKFLOW_EXECUTION_ID, - ), - conversation_id_getter=self._conversation_id, - ) - self._tool_runtime = DifyToolNodeRuntime(self._dify_context) - 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 with_runtime_state(self, graph_runtime_state: "GraphRuntimeState") -> "DifyNodeFactory": - return DifyNodeFactory( - graph_init_params=self.graph_init_params, - graph_runtime_state=graph_runtime_state, - ) - - @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.graph_runtime_state.variable_pool, SystemVariableKey.CONVERSATION_ID) - - @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 - """ - adapted_node_config = adapt_node_config_for_graph(node_config) - typed_node_config = NodeConfigDictAdapter.validate_python(adapted_node_config) - node_id = typed_node_config["id"] - node_data = typed_node_config["data"] - node_class = self._resolve_node_class( - node_type=node_data.type, - node_version=str(node_data.version), - node_data=node_data, - ) - # 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 = self._validate_resolved_node_data(node_class, node_data) - 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: { - "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), - } - 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, - graph_init_params=self.graph_init_params, - graph_runtime_state=self.graph_runtime_state, - **node_init_kwargs, - ) - return node - - @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 - - @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.graph_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 - - return { - "binding_resolver": WorkflowAgentBindingResolver(), - "runtime_request_builder": WorkflowAgentRuntimeRequestBuilder( - request_builder=AgentBackendRunRequestBuilder(), - ), - "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(), - } - 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.graph_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.graph_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, - ) diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py deleted file mode 100644 index 8fcbcc7d237d2a..00000000000000 --- a/api/core/workflow/node_runtime.py +++ /dev/null @@ -1,1001 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Generator, Mapping, Sequence -from dataclasses import dataclass -from enum import Enum -from typing import TYPE_CHECKING, Any, Literal, Protocol, cast, overload, override - -from pydantic import JsonValue -from sqlalchemy import select -from sqlalchemy.orm import Session, sessionmaker - -from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext -from core.app.file_access import ( - DatabaseFileAccessController, - grant_upload_file_access, - is_retriever_segment_access_granted, -) -from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler -from core.db.session_factory import session_factory -from core.helper.trace_id_helper import ParentTraceContext -from core.llm_generator.output_parser.errors import OutputParserError -from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output -from core.model_context import use_credit_usage_metadata -from core.model_manager import ModelInstance, QuotaManagedModelInstance -from core.plugin.impl.exc import PluginDaemonClientSideError, PluginInvokeError -from core.plugin.impl.plugin import PluginInstaller -from core.prompt.utils.prompt_message_util import PromptMessageUtil -from core.repositories.human_input_repository import ( - FormCreateParams, - HumanInputFormEntity, - HumanInputFormRepository, - HumanInputFormRepositoryImpl, -) -from core.tools.entities.tool_entities import ToolProviderType as CoreToolProviderType -from core.tools.errors import ToolInvokeError -from core.tools.tool_engine import ToolEngine -from core.tools.tool_file_manager import ToolFileManager -from core.tools.tool_manager import ToolManager -from core.tools.utils.message_transformer import ToolFileMessageTransformer -from core.workflow.file_reference import build_file_reference -from core.workflow.human_input_adapter import is_human_input_webapp_enabled, parse_human_input_delivery_methods -from enums.human_input import DeliveryMethodType -from extensions.ext_database import db -from factories import file_factory -from graphon.file import File, FileTransferMethod, FileType -from graphon.model_runtime.entities import LLMMode -from graphon.model_runtime.entities.llm_entities import ( - LLMPollingResult, - LLMPollingStatus, - LLMResult, - LLMResultChunk, - LLMResultChunkWithStructuredOutput, - LLMResultWithStructuredOutput, - LLMUsage, -) -from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageTool -from graphon.model_runtime.entities.model_entities import AIModelEntity -from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel -from graphon.nodes.llm.runtime_protocols import ( - LLMPollingCapableProtocol, - LLMProtocol, - PromptMessageSerializerProtocol, - RetrieverAttachmentLoaderProtocol, -) -from graphon.nodes.protocols import FileReferenceFactoryProtocol, HttpClientProtocol, ToolFileManagerProtocol -from graphon.nodes.runtime import ToolNodeRuntimeProtocol -from graphon.nodes.tool.exc import ToolNodeError, ToolRuntimeInvocationError, ToolRuntimeResolutionError -from graphon.nodes.tool_runtime_entities import ( - ToolRuntimeHandle, - ToolRuntimeMessage, - ToolRuntimeParameter, -) -from models.dataset import SegmentAttachmentBinding -from models.human_input_delivery import BoundRecipient, DeliveryChannelConfig, EmailDeliveryMethod, EmailRecipients -from models.human_input_entities import FileInputConfig, FileListInputConfig, FormInputConfig, HumanInputNodeData -from models.model import UploadFile -from services.tools.legacy_builtin_tools_manage_service import BuiltinToolManageService - -from .system_variables import SystemVariableKey, get_system_text - - -class PollingLLMRuntimeProtocol(Protocol): - """Runtime capability required by the workflow polling adapter.""" - - def start_llm_polling( - self, - *, - provider: str, - model: str, - credentials: dict[str, Any], - model_parameters: dict[str, Any], - prompt_messages: Sequence[PromptMessage], - tools: Sequence[PromptMessageTool] | None, - stop: Sequence[str] | None, - json_schema: dict[str, Any] | None, - ) -> LLMPollingResult: ... - - def check_llm_polling( - self, - *, - provider: str, - model: str, - credentials: dict[str, Any], - plugin_state: dict[str, JsonValue], - ) -> LLMPollingResult: ... - - -if TYPE_CHECKING: - from core.tools.__base.tool import Tool - from core.tools.entities.tool_entities import ToolInvokeMessage as CoreToolInvokeMessage - from graphon.nodes.llm.file_saver import LLMFileSaver - from graphon.nodes.tool.entities import ToolNodeData - - -_file_access_controller = DatabaseFileAccessController() - - -def resolve_dify_run_context(run_context: Mapping[str, Any] | DifyRunContext) -> DifyRunContext: - if isinstance(run_context, DifyRunContext): - return run_context - - 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 apply_dify_debug_email_recipient( - method: DeliveryChannelConfig, - *, - enabled: bool, - actor_id: str | None, -) -> DeliveryChannelConfig: - """Apply the Dify debugger-specific email recipient override outside `graphon`.""" - if not enabled: - return method - if not isinstance(method, EmailDeliveryMethod): - return method - if not method.config.debug_mode: - return method - - if actor_id is None: - debug_recipients = EmailRecipients(include_bound_group=False, items=[]) - else: - debug_recipients = EmailRecipients( - include_bound_group=False, - items=[BoundRecipient(reference_id=actor_id)], - ) - debug_config = method.config.with_recipients(debug_recipients) - return method.model_copy(update={"config": debug_config}) - - -class DifyFileReferenceFactory(FileReferenceFactoryProtocol): - def __init__(self, run_context: Mapping[str, Any] | DifyRunContext) -> None: - self._run_context = resolve_dify_run_context(run_context) - - @override - def build_from_mapping(self, *, mapping: Mapping[str, Any]): - return file_factory.build_from_mapping( - mapping=mapping, - tenant_id=self._run_context.tenant_id, - access_controller=_file_access_controller, - ) - - -class DifyPreparedLLM(LLMProtocol): - """Workflow-layer adapter that hides the full `ModelInstance` API from `graphon` nodes.""" - - def __init__(self, model_instance: ModelInstance, request_metadata: Mapping[str, object] | None = None) -> None: - self._model_instance = model_instance - self._request_metadata = request_metadata - - @property - @override - def provider(self) -> str: - return self._model_instance.provider - - @property - @override - def model_name(self) -> str: - return self._model_instance.model_name - - @property - @override - def parameters(self) -> Mapping[str, Any]: - return self._model_instance.parameters - - @parameters.setter - @override - def parameters(self, value: Mapping[str, Any]) -> None: - self._model_instance.parameters = value - - @property - @override - def stop(self) -> Sequence[str] | None: - return self._model_instance.stop - - @override - def get_model_schema(self) -> AIModelEntity: - model_schema = cast(LargeLanguageModel, self._model_instance.model_type_instance).get_model_schema( - self._model_instance.model_name, - self._model_instance.credentials, - ) - if model_schema is None: - raise ValueError(f"Model schema not found for {self._model_instance.model_name}") - return model_schema - - @override - def get_llm_num_tokens(self, prompt_messages: Sequence[PromptMessage]) -> int: - return self._model_instance.get_llm_num_tokens(prompt_messages) - - @overload - def invoke_llm( - self, - *, - prompt_messages: Sequence[PromptMessage], - model_parameters: Mapping[str, Any], - tools: Sequence[PromptMessageTool] | None, - stop: Sequence[str] | None, - stream: Literal[False], - ) -> LLMResult: ... - - @overload - def invoke_llm( - self, - *, - prompt_messages: Sequence[PromptMessage], - model_parameters: Mapping[str, Any], - tools: Sequence[PromptMessageTool] | None, - stop: Sequence[str] | None, - stream: Literal[True], - ) -> Generator[LLMResultChunk, None, None]: ... - - @override - def invoke_llm( - self, - *, - prompt_messages: Sequence[PromptMessage], - model_parameters: Mapping[str, Any], - tools: Sequence[PromptMessageTool] | None, - stop: Sequence[str] | None, - stream: bool, - ) -> LLMResult | Generator[LLMResultChunk, None, None]: - return self._model_instance.invoke_llm( - prompt_messages=list(prompt_messages), - model_parameters=dict(model_parameters), - tools=list(tools or []), - stop=list(stop or []), - stream=stream, - request_metadata=self._request_metadata, - ) - - @overload - def invoke_llm_with_structured_output( - self, - *, - prompt_messages: Sequence[PromptMessage], - json_schema: Mapping[str, Any], - model_parameters: Mapping[str, Any], - stop: Sequence[str] | None, - stream: Literal[False], - ) -> LLMResultWithStructuredOutput: ... - - @overload - def invoke_llm_with_structured_output( - self, - *, - prompt_messages: Sequence[PromptMessage], - json_schema: Mapping[str, Any], - model_parameters: Mapping[str, Any], - stop: Sequence[str] | None, - stream: Literal[True], - ) -> Generator[LLMResultChunkWithStructuredOutput, None, None]: ... - - @override - def invoke_llm_with_structured_output( - self, - *, - prompt_messages: Sequence[PromptMessage], - json_schema: Mapping[str, Any], - model_parameters: Mapping[str, Any], - stop: Sequence[str] | None, - stream: bool, - ) -> LLMResultWithStructuredOutput | Generator[LLMResultChunkWithStructuredOutput, None, None]: - return invoke_llm_with_structured_output( - provider=self.provider, - model_schema=self.get_model_schema(), - model_instance=self._model_instance, - prompt_messages=prompt_messages, - json_schema=json_schema, - model_parameters=model_parameters, - stop=list(stop or []), - stream=stream, - request_metadata=self._request_metadata, - ) - - @override - def is_structured_output_parse_error(self, error: Exception) -> bool: - return isinstance(error, OutputParserError) - - def finalize_llm_polling(self) -> None: - """Finalize resources held by a polling invocation, if any.""" - - -class DifyPreparedPollingLLM(DifyPreparedLLM, LLMPollingCapableProtocol): - """Prepared workflow LLM adapter that exposes Graphon's polling protocol.""" - - def __init__(self, model_instance: ModelInstance, request_metadata: Mapping[str, object] | None = None) -> None: - super().__init__(model_instance, request_metadata=request_metadata) - model_type_instance = cast(LargeLanguageModel, model_instance.model_type_instance) - self._polling_runtime = cast(PollingLLMRuntimeProtocol, model_type_instance.model_runtime) - self._polling_quota_reservation = None - - @override - def finalize_llm_polling(self) -> None: - reservation = self._polling_quota_reservation - self._polling_quota_reservation = None - if reservation is not None: - QuotaManagedModelInstance.release_quota_safely(reservation) - - def _settle_polling_quota(self, polling_result: LLMPollingResult) -> LLMPollingResult: - reservation = self._polling_quota_reservation - if reservation is None or polling_result.status == LLMPollingStatus.RUNNING: - return polling_result - - try: - if polling_result.status == LLMPollingStatus.SUCCEEDED: - if polling_result.result is None: - raise ValueError("A successful LLM polling result must include a model result.") - reservation.commit(polling_result.result.usage) - else: - reservation.release() - except Exception: - QuotaManagedModelInstance.release_quota_safely(reservation) - raise - finally: - self._polling_quota_reservation = None - - return polling_result - - @override - def start_llm_polling( - self, - *, - prompt_messages: Sequence[PromptMessage], - model_parameters: Mapping[str, Any], - tools: Sequence[PromptMessageTool] | None, - stop: Sequence[str] | None, - json_schema: Mapping[str, Any] | None, - ) -> LLMPollingResult: - self.finalize_llm_polling() - - if isinstance(self._model_instance, QuotaManagedModelInstance): - self._polling_quota_reservation = self._model_instance._reserve_quota_for_request(self._request_metadata) - - try: - polling_result = self._polling_runtime.start_llm_polling( - provider=self.provider, - model=self.model_name, - credentials=self._model_instance.credentials, - prompt_messages=prompt_messages, - model_parameters=dict(model_parameters), - tools=tools, - stop=stop, - json_schema=dict(json_schema) if json_schema is not None else None, - ) - return self._settle_polling_quota(polling_result) - except Exception: - self.finalize_llm_polling() - raise - - @override - def check_llm_polling( - self, - *, - plugin_state: Mapping[str, JsonValue], - ) -> LLMPollingResult: - try: - polling_result = self._polling_runtime.check_llm_polling( - provider=self.provider, - model=self.model_name, - credentials=self._model_instance.credentials, - plugin_state=dict(plugin_state), - ) - return self._settle_polling_quota(polling_result) - except Exception: - self.finalize_llm_polling() - raise - - -class DifyPromptMessageSerializer(PromptMessageSerializerProtocol): - @override - def serialize( - self, - *, - model_mode: LLMMode, - prompt_messages: Sequence[PromptMessage], - ) -> Any: - return PromptMessageUtil.prompt_messages_to_prompt_for_saving( - model_mode=model_mode, - prompt_messages=prompt_messages, - ) - - -class DifyRetrieverAttachmentLoader(RetrieverAttachmentLoaderProtocol): - """Resolve retriever attachments through Dify persistence and return graph file references.""" - - _segment_access_checker: Callable[[str], bool] | None - - def __init__( - self, - *, - file_reference_factory: FileReferenceFactoryProtocol, - segment_access_checker: Callable[[str], bool] | None = None, - ) -> None: - self._file_reference_factory = file_reference_factory - self._segment_access_checker = segment_access_checker - - @override - def load(self, *, segment_id: str) -> Sequence[File]: - if not is_retriever_segment_access_granted(segment_id): - return [] - if self._segment_access_checker is not None and not self._segment_access_checker(segment_id): - return [] - - with Session(db.engine, expire_on_commit=False) as session: - attachments_with_bindings = session.execute( - select(SegmentAttachmentBinding, UploadFile) - .join(UploadFile, UploadFile.id == SegmentAttachmentBinding.attachment_id) - .where(SegmentAttachmentBinding.segment_id == segment_id) - ).all() - - grant_upload_file_access(str(upload_file.id) for _, upload_file in attachments_with_bindings) - return [ - self._file_reference_factory.build_from_mapping( - mapping={ - "id": upload_file.id, - "filename": upload_file.name, - "extension": "." + upload_file.extension, - "mime_type": upload_file.mime_type, - "type": FileType.IMAGE, - "transfer_method": FileTransferMethod.LOCAL_FILE, - "remote_url": upload_file.source_url, - "reference": build_file_reference(record_id=str(upload_file.id)), - "size": upload_file.size, - } - ) - for _, upload_file in attachments_with_bindings - ] - - -class DifyToolFileManager(ToolFileManagerProtocol): - """Workflow adapter that resolves conversation scope outside `graphon`.""" - - _conversation_id_getter: Callable[[], str | None] | None - - def __init__( - self, - run_context: Mapping[str, Any] | DifyRunContext, - *, - conversation_id_getter: Callable[[], str | None] | None = None, - ) -> None: - self._run_context = resolve_dify_run_context(run_context) - self._manager = ToolFileManager() - self._conversation_id_getter = conversation_id_getter - - @override - def create_file_by_raw( - self, - *, - file_binary: bytes, - mimetype: str, - filename: str | None = None, - ) -> Any: - conversation_id = self._conversation_id_getter() if self._conversation_id_getter is not None else None - return self._manager.create_file_by_raw( - user_id=self._run_context.user_id, - tenant_id=self._run_context.tenant_id, - conversation_id=conversation_id, - file_binary=file_binary, - mimetype=mimetype, - filename=filename, - ) - - @override - def get_file_generator_by_tool_file_id(self, tool_file_id: str): - return self._manager.get_file_generator_by_tool_file_id(tool_file_id) - - -@dataclass(frozen=True, slots=True) -class _WorkflowToolRuntimeSpec: - provider_type: CoreToolProviderType - provider_id: str - tool_name: str - tool_configurations: dict[str, Any] - credential_id: str | None = None - - -@dataclass(frozen=True, slots=True) -class _WorkflowToolRuntimeBinding: - """Workflow-private runtime state stored inside the opaque graph handle. - - The binding keeps conversation scope in `core.workflow` while `graphon` - continues to treat the handle as an opaque token. - """ - - tool: Tool - conversation_id: str | None = None - parent_trace_context: ParentTraceContext | None = None - trace_session_id: str | None = None - - -class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): - def __init__( - self, - run_context: Mapping[str, Any] | DifyRunContext, - session_maker: sessionmaker[Session] | None = None, - ) -> None: - self._run_context = resolve_dify_run_context(run_context) - self._file_reference_factory = DifyFileReferenceFactory(self._run_context) - self._session_maker = session_maker - - @property - def file_reference_factory(self) -> FileReferenceFactoryProtocol: - return self._file_reference_factory - - @override - def build_file_reference(self, *, mapping: Mapping[str, Any]): - return self._file_reference_factory.build_from_mapping(mapping=mapping) - - @override - def get_runtime( - self, - *, - node_id: str, - node_data: ToolNodeData, - variable_pool, - node_execution_id: str | None = None, - ) -> ToolRuntimeHandle: - try: - tool_runtime = ToolManager.get_workflow_tool_runtime( - self._run_context.tenant_id, - self._run_context.app_id, - node_id, - self._build_tool_runtime_spec(node_data), - self._run_context.user_id, - self._run_context.invoke_from, - variable_pool, - ) - except ToolNodeError: - raise - except Exception as exc: - raise ToolRuntimeResolutionError(str(exc)) from exc - - conversation_id = ( - None if variable_pool is None else get_system_text(variable_pool, SystemVariableKey.CONVERSATION_ID) - ) - parent_trace_context: ParentTraceContext | None = None - trace_session_id: str | None = None - if self._is_workflow_tool_provider(node_data): - outer_workflow_run_id = ( - None - if variable_pool is None - else get_system_text(variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID) - ) - if isinstance(outer_workflow_run_id, str) and isinstance(node_execution_id, str): - parent_trace_context = ParentTraceContext( - parent_workflow_run_id=outer_workflow_run_id, - parent_node_execution_id=node_execution_id, - ) - if isinstance(self._run_context.trace_session_id, str) and self._run_context.trace_session_id: - trace_session_id = self._run_context.trace_session_id - return ToolRuntimeHandle( - raw=_WorkflowToolRuntimeBinding( - tool=tool_runtime, - conversation_id=conversation_id, - parent_trace_context=parent_trace_context, - trace_session_id=trace_session_id, - ) - ) - - @override - def get_runtime_parameters( - self, - *, - tool_runtime: ToolRuntimeHandle, - ) -> Sequence[ToolRuntimeParameter]: - tool = self._tool_from_handle(tool_runtime) - return [ - ToolRuntimeParameter(name=parameter.name, required=parameter.required) - for parameter in (tool.get_merged_runtime_parameters() or []) - ] - - @override - def invoke( - self, - *, - tool_runtime: ToolRuntimeHandle, - tool_parameters: Mapping[str, Any], - workflow_call_depth: int, - provider_name: str, - ) -> Generator[ToolRuntimeMessage, None, None]: - runtime_binding = self._binding_from_handle(tool_runtime) - tool = runtime_binding.tool - callback = DifyWorkflowCallbackHandler() - if runtime_binding.parent_trace_context and hasattr(tool, "set_parent_trace_context"): - tool.set_parent_trace_context( - parent_workflow_run_id=runtime_binding.parent_trace_context.parent_workflow_run_id, - parent_node_execution_id=runtime_binding.parent_trace_context.parent_node_execution_id, - ) - elif hasattr(tool, "clear_parent_trace_context"): - tool.clear_parent_trace_context() - if runtime_binding.trace_session_id and hasattr(tool, "set_trace_session_id"): - tool.set_trace_session_id(runtime_binding.trace_session_id) - elif hasattr(tool, "clear_trace_session_id"): - tool.clear_trace_session_id() - - try: - request_metadata = ( - {"app_type": self._run_context.app_type} if self._run_context.app_type is not None else None - ) - with use_credit_usage_metadata(request_metadata): - session_maker = self._session_maker or session_factory.get_session_maker() - with session_maker.begin() as session: - messages = ToolEngine.generic_invoke( - session=session, - tool=tool, - tool_parameters=dict(tool_parameters), - user_id=self._run_context.user_id, - workflow_tool_callback=callback, - workflow_call_depth=workflow_call_depth, - app_id=self._run_context.app_id, - conversation_id=runtime_binding.conversation_id, - ) - transformed_messages = ToolFileMessageTransformer.transform_tool_invoke_messages( - messages=messages, - user_id=self._run_context.user_id, - tenant_id=self._run_context.tenant_id, - conversation_id=runtime_binding.conversation_id, - ) - yield from self._adapt_messages(transformed_messages, provider_name=provider_name) - except Exception as exc: - raise self._map_invocation_exception(exc, provider_name=provider_name) from exc - - @override - def get_usage( - self, - *, - tool_runtime: ToolRuntimeHandle, - ) -> LLMUsage: - latest = getattr(self._binding_from_handle(tool_runtime).tool, "latest_usage", None) - if isinstance(latest, LLMUsage): - return latest - if isinstance(latest, dict): - return LLMUsage.model_validate(latest) - return LLMUsage.empty_usage() - - def resolve_provider_icons( - self, - *, - provider_name: str, - default_icon: str | None = None, - ) -> tuple[str | Mapping[str, str] | None, str | Mapping[str, str] | None]: - icon: str | Mapping[str, str] | None = default_icon - icon_dark: str | Mapping[str, str] | None = None - - manager = PluginInstaller() - plugins = manager.list_plugins(self._run_context.tenant_id) - try: - current_plugin = next(plugin for plugin in plugins if f"{plugin.plugin_id}/{plugin.name}" == provider_name) - icon = current_plugin.declaration.icon - except StopIteration: - pass - - try: - builtin_tool = next( - provider - for provider in BuiltinToolManageService.list_builtin_tools( - self._run_context.user_id, - self._run_context.tenant_id, - ) - if provider.name == provider_name - ) - icon = builtin_tool.icon - icon_dark = builtin_tool.icon_dark - except StopIteration: - pass - - return icon, icon_dark - - @staticmethod - def _tool_from_handle(tool_runtime: ToolRuntimeHandle) -> Tool: - return DifyToolNodeRuntime._binding_from_handle(tool_runtime).tool - - @staticmethod - def _binding_from_handle(tool_runtime: ToolRuntimeHandle) -> _WorkflowToolRuntimeBinding: - if isinstance(tool_runtime.raw, _WorkflowToolRuntimeBinding): - return tool_runtime.raw - return _WorkflowToolRuntimeBinding(tool=cast("Tool", tool_runtime.raw)) - - @staticmethod - def _build_tool_runtime_spec(node_data: ToolNodeData) -> _WorkflowToolRuntimeSpec: - tool_configurations = dict(node_data.tool_configurations) - tool_configurations.update( - {name: tool_input.model_dump(mode="python") for name, tool_input in node_data.tool_parameters.items()} - ) - return _WorkflowToolRuntimeSpec( - provider_type=CoreToolProviderType(node_data.provider_type.value), - provider_id=node_data.provider_id, - tool_name=node_data.tool_name, - tool_configurations=tool_configurations, - credential_id=node_data.credential_id, - ) - - @staticmethod - def _is_workflow_tool_provider(node_data: ToolNodeData) -> bool: - return node_data.provider_type.value == CoreToolProviderType.WORKFLOW.value - - def _adapt_messages( - self, - messages: Generator[CoreToolInvokeMessage, None, None], - *, - provider_name: str, - ) -> Generator[ToolRuntimeMessage, None, None]: - try: - for message in messages: - yield self._convert_message(message) - except Exception as exc: - raise self._map_invocation_exception(exc, provider_name=provider_name) from exc - - def _convert_message(self, message: CoreToolInvokeMessage) -> ToolRuntimeMessage: - graph_message_type = ToolRuntimeMessage.MessageType(message.type.value) - graph_message = self._convert_message_payload(message.message) - graph_meta = message.meta.copy() if message.meta is not None else None - return ToolRuntimeMessage(type=graph_message_type, message=graph_message, meta=graph_meta) - - def _convert_message_payload( - self, - message: CoreToolInvokeMessage.TextMessage - | CoreToolInvokeMessage.JsonMessage - | CoreToolInvokeMessage.BlobChunkMessage - | CoreToolInvokeMessage.BlobMessage - | CoreToolInvokeMessage.LogMessage - | CoreToolInvokeMessage.FileMessage - | CoreToolInvokeMessage.VariableMessage - | CoreToolInvokeMessage.RetrieverResourceMessage - | None, - ) -> ( - ToolRuntimeMessage.TextMessage - | ToolRuntimeMessage.JsonMessage - | ToolRuntimeMessage.BlobChunkMessage - | ToolRuntimeMessage.BlobMessage - | ToolRuntimeMessage.LogMessage - | ToolRuntimeMessage.FileMessage - | ToolRuntimeMessage.VariableMessage - | ToolRuntimeMessage.RetrieverResourceMessage - | None - ): - if message is None: - return None - - from core.tools.entities.tool_entities import ToolInvokeMessage as CoreToolInvokeMessage - - match message: - case CoreToolInvokeMessage.TextMessage(): - return ToolRuntimeMessage.TextMessage(text=message.text) - case CoreToolInvokeMessage.JsonMessage(): - return ToolRuntimeMessage.JsonMessage( - json_object=message.json_object, - suppress_output=message.suppress_output, - ) - case CoreToolInvokeMessage.BlobMessage(): - return ToolRuntimeMessage.BlobMessage(blob=message.blob) - case CoreToolInvokeMessage.BlobChunkMessage(): - return ToolRuntimeMessage.BlobChunkMessage( - id=message.id, - sequence=message.sequence, - total_length=message.total_length, - blob=message.blob, - end=message.end, - ) - case CoreToolInvokeMessage.FileMessage(): - return ToolRuntimeMessage.FileMessage(file_marker=message.file_marker) - case CoreToolInvokeMessage.VariableMessage(): - return ToolRuntimeMessage.VariableMessage( - variable_name=message.variable_name, - variable_value=message.variable_value, - stream=message.stream, - ) - case CoreToolInvokeMessage.LogMessage(): - return ToolRuntimeMessage.LogMessage( - id=message.id, - label=message.label, - parent_id=message.parent_id, - error=message.error, - status=ToolRuntimeMessage.LogMessage.LogStatus(message.status.value), - data=dict(message.data), - metadata=dict(message.metadata), - ) - case CoreToolInvokeMessage.RetrieverResourceMessage(): - retriever_resources = [ - resource.model_dump() if hasattr(resource, "model_dump") else dict(resource) - for resource in message.retriever_resources - ] - return ToolRuntimeMessage.RetrieverResourceMessage( - retriever_resources=retriever_resources, - context=message.context, - ) - case _: - raise TypeError(f"unsupported tool message payload: {type(message).__name__}") - - @staticmethod - def _map_invocation_exception(exc: Exception, *, provider_name: str) -> ToolNodeError: - match exc: - case ToolNodeError(): - return exc - case PluginInvokeError(): - return ToolRuntimeInvocationError(exc.to_user_friendly_error(plugin_name=provider_name)) - case PluginDaemonClientSideError(): - return ToolRuntimeInvocationError(f"Failed to invoke tool, error: {exc.description}") - case ToolInvokeError(): - return ToolRuntimeInvocationError(f"Failed to invoke tool {provider_name}: {exc}") - case _: - return ToolRuntimeInvocationError(str(exc)) - - -class DifyHumanInputNodeRuntime: - def __init__( - self, - run_context: Mapping[str, Any] | DifyRunContext, - *, - workflow_execution_id_getter: Callable[[], str | None] | None = None, - conversation_id_getter: Callable[[], str | None] | None = None, - form_repository: HumanInputFormRepository | None = None, - ) -> None: - self._run_context = resolve_dify_run_context(run_context) - self._workflow_execution_id_getter = workflow_execution_id_getter - self._conversation_id_getter = conversation_id_getter - self._form_repository = form_repository - self._file_reference_factory = DifyFileReferenceFactory(self._run_context) - - def _invoke_source(self) -> str: - invoke_from = self._run_context.invoke_from - if isinstance(invoke_from, str): - return invoke_from - if isinstance(invoke_from, Enum): - return str(invoke_from.value) - return str(invoke_from) - - def _resolve_delivery_methods(self, *, node_data: HumanInputNodeData) -> Sequence[DeliveryChannelConfig]: - invoke_source = self._invoke_source() - methods = [method for method in parse_human_input_delivery_methods(node_data) if method.enabled] - if invoke_source in {"debugger", "explore"}: - methods = [method for method in methods if method.type != DeliveryMethodType.WEBAPP] - return [ - apply_dify_debug_email_recipient( - method, - enabled=invoke_source == "debugger", - actor_id=self._run_context.user_id, - ) - for method in methods - ] - - def _display_in_ui(self, *, node_data: HumanInputNodeData) -> bool: - if self._invoke_source() == "debugger": - return True - return is_human_input_webapp_enabled(node_data) - - def build_form_repository(self) -> HumanInputFormRepository: - if self._form_repository is not None: - return self._form_repository - - return self._build_form_repository() - - def _build_form_repository(self) -> HumanInputFormRepository: - invoke_source = self._invoke_source() - return HumanInputFormRepositoryImpl( - tenant_id=self._run_context.tenant_id, - app_id=self._run_context.app_id, - workflow_execution_id=self._workflow_execution_id_getter() if self._workflow_execution_id_getter else None, - invoke_source=invoke_source, - submission_actor_id=self._run_context.user_id if invoke_source in {"debugger", "explore"} else None, - ) - - def with_form_repository(self, form_repository: HumanInputFormRepository) -> DifyHumanInputNodeRuntime: - return DifyHumanInputNodeRuntime( - self._run_context, - workflow_execution_id_getter=self._workflow_execution_id_getter, - conversation_id_getter=self._conversation_id_getter, - form_repository=form_repository, - ) - - def get_form(self, *, node_id: str) -> HumanInputFormEntity | None: - repo = self.build_form_repository() - return repo.get_form(node_id) - - def restore_submitted_data( - self, - *, - node_data: HumanInputNodeData, - submitted_data: Mapping[str, Any], - ) -> Mapping[str, Any]: - restored_data: dict[str, Any] = dict(submitted_data) - for input_config in node_data.inputs: - output_variable_name = input_config.output_variable_name - if output_variable_name not in submitted_data: - continue - restored_data[output_variable_name] = self._restore_submitted_value( - input_config=input_config, - value=submitted_data[output_variable_name], - ) - return restored_data - - def create_form( - self, - *, - node_id: str, - node_data: HumanInputNodeData, - rendered_content: str, - resolved_default_values: Mapping[str, Any], - ) -> HumanInputFormEntity: - repo = self.build_form_repository() - params = FormCreateParams( - workflow_execution_id=self._workflow_execution_id_getter() if self._workflow_execution_id_getter else None, - # A chatflow (advanced-chat) run carries a conversation; tag the form with - # it too so it is queryable per conversation. None for a pure workflow run. - conversation_id=self._conversation_id_getter() if self._conversation_id_getter else None, - node_id=node_id, - form_config=node_data, - rendered_content=rendered_content, - delivery_methods=self._resolve_delivery_methods(node_data=node_data), - display_in_ui=self._display_in_ui(node_data=node_data), - resolved_default_values=resolved_default_values, - ) - return repo.create_form(params) - - def _restore_submitted_value( - self, - *, - input_config: FormInputConfig, - value: Any, - ) -> Any: - if isinstance(input_config, FileInputConfig): - return self._restore_submitted_file_value( - output_variable_name=input_config.output_variable_name, - value=value, - ) - if isinstance(input_config, FileListInputConfig): - return self._restore_submitted_file_list_value( - output_variable_name=input_config.output_variable_name, - value=value, - ) - return value - - def _restore_submitted_file_value( - self, - *, - output_variable_name: str, - value: Any, - ) -> Any: - if not isinstance(value, Mapping): - msg = ( - "HumanInput file submission must be persisted as a mapping, " - f"output_variable_name={output_variable_name}" - ) - raise ValueError(msg) - return self._file_reference_factory.build_from_mapping(mapping=value) - - def _restore_submitted_file_list_value( - self, - *, - output_variable_name: str, - value: Any, - ) -> list[Any]: - if not isinstance(value, list): - msg = ( - "HumanInput file-list submission must be persisted as a list, " - f"output_variable_name={output_variable_name}" - ) - raise ValueError(msg) - if any(not isinstance(item, Mapping) for item in value): - msg = f"HumanInput file-list submission must contain mappings, output_variable_name={output_variable_name}" - raise ValueError(msg) - return [self._file_reference_factory.build_from_mapping(mapping=item) for item in value] - - -def build_dify_llm_file_saver( - *, - run_context: Mapping[str, Any] | DifyRunContext, - http_client: HttpClientProtocol, - conversation_id_getter: Callable[[], str | None] | None = None, -) -> LLMFileSaver: - from graphon.nodes.llm.file_saver import FileSaverImpl - - return FileSaverImpl( - tool_file_manager=DifyToolFileManager(run_context, conversation_id_getter=conversation_id_getter), - file_reference_factory=DifyFileReferenceFactory(run_context), - http_client=http_client, - ) diff --git a/api/core/workflow/nodes/agent/agent_node.py b/api/core/workflow/nodes/agent/agent_node.py deleted file mode 100644 index 90536f7ff821d7..00000000000000 --- a/api/core/workflow/nodes/agent/agent_node.py +++ /dev/null @@ -1,222 +0,0 @@ -from __future__ import annotations - -from collections.abc import Generator, Mapping, Sequence -from functools import singledispatchmethod -from typing import TYPE_CHECKING, Any, override - -from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext -from core.workflow.system_variables import SystemVariableKey, get_system_text -from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus -from graphon.graph_events import GraphNodeEventBase -from graphon.node_events import NodeEventBase, NodeRunResult, StreamCompletedEvent -from graphon.nodes.base.node import Node -from graphon.nodes.base.variable_template_parser import VariableTemplateParser - -from .entities import AgentNodeData -from .events import AgentLogEvent, NodeRunAgentLogEvent -from .exceptions import ( - AgentInvocationError, - AgentMessageTransformError, -) -from .message_transformer import AgentMessageTransformer -from .runtime_support import AgentRuntimeSupport -from .strategy_protocols import AgentStrategyPresentationProvider, AgentStrategyResolver - -if TYPE_CHECKING: - from graphon.entities import GraphInitParams - from graphon.runtime import GraphRuntimeState - - -class AgentNode(Node[AgentNodeData]): - node_type = BuiltinNodeTypes.AGENT - - _strategy_resolver: AgentStrategyResolver - _presentation_provider: AgentStrategyPresentationProvider - _runtime_support: AgentRuntimeSupport - _message_transformer: AgentMessageTransformer - - def __init__( - self, - node_id: str, - data: AgentNodeData, - *, - graph_init_params: GraphInitParams, - graph_runtime_state: GraphRuntimeState, - strategy_resolver: AgentStrategyResolver, - presentation_provider: AgentStrategyPresentationProvider, - runtime_support: AgentRuntimeSupport, - message_transformer: AgentMessageTransformer, - ) -> None: - super().__init__( - node_id=node_id, - data=data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, - ) - self._strategy_resolver = strategy_resolver - self._presentation_provider = presentation_provider - self._runtime_support = runtime_support - self._message_transformer = message_transformer - - @classmethod - @override - def version(cls) -> str: - return "1" - - @override - def populate_start_event(self, event) -> None: - dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY)) - event.extras["agent_strategy"] = { - "name": self.node_data.agent_strategy_name, - "icon": self._presentation_provider.get_icon( - tenant_id=dify_ctx.tenant_id, - agent_strategy_provider_name=self.node_data.agent_strategy_provider_name, - ), - } - - @override - @singledispatchmethod - def _dispatch( # pyrefly: ignore[missing-override-decorator] - self, event: NodeEventBase - ) -> GraphNodeEventBase: - return super()._dispatch(event) - - @_dispatch.register - def _dispatch_agent_log(self, event: AgentLogEvent) -> NodeRunAgentLogEvent: - return NodeRunAgentLogEvent( - id=self.execution_id, - node_id=self._node_id, - node_type=self.node_type, - message_id=event.message_id, - label=event.label, - node_execution_id=event.node_execution_id, - parent_id=event.parent_id, - error=event.error, - status=event.status, - data=event.data, - metadata=event.metadata, - ) - - @override - def _run(self) -> Generator[NodeEventBase, None, None]: - from core.plugin.impl.exc import PluginDaemonClientSideError - - dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY)) - - try: - strategy = self._strategy_resolver.resolve( - tenant_id=dify_ctx.tenant_id, - agent_strategy_provider_name=self.node_data.agent_strategy_provider_name, - agent_strategy_name=self.node_data.agent_strategy_name, - ) - except Exception as e: - yield StreamCompletedEvent( - node_run_result=NodeRunResult( - status=WorkflowNodeExecutionStatus.FAILED, - inputs={}, - error=f"Failed to get agent strategy: {str(e)}", - ), - ) - return - - agent_parameters = strategy.get_parameters() - - parameters = self._runtime_support.build_parameters( - agent_parameters=agent_parameters, - variable_pool=self.graph_runtime_state.variable_pool, - node_data=self.node_data, - strategy=strategy, - tenant_id=dify_ctx.tenant_id, - user_id=dify_ctx.user_id, - app_id=dify_ctx.app_id, - invoke_from=dify_ctx.invoke_from, - ) - parameters_for_log = self._runtime_support.build_parameters( - agent_parameters=agent_parameters, - variable_pool=self.graph_runtime_state.variable_pool, - node_data=self.node_data, - strategy=strategy, - tenant_id=dify_ctx.tenant_id, - user_id=dify_ctx.user_id, - app_id=dify_ctx.app_id, - invoke_from=dify_ctx.invoke_from, - for_log=True, - ) - credentials = self._runtime_support.build_credentials(parameters=parameters) - - conversation_id = get_system_text(self.graph_runtime_state.variable_pool, SystemVariableKey.CONVERSATION_ID) - - try: - message_stream = strategy.invoke( - params=parameters, - user_id=dify_ctx.user_id, - app_id=dify_ctx.app_id, - conversation_id=conversation_id, - credentials=credentials, - ) - except Exception as e: - error = AgentInvocationError(f"Failed to invoke agent: {str(e)}", original_error=e) - yield StreamCompletedEvent( - node_run_result=NodeRunResult( - status=WorkflowNodeExecutionStatus.FAILED, - inputs=parameters_for_log, - error=str(error), - ) - ) - return - - try: - yield from self._message_transformer.transform( - messages=message_stream, - tool_info={ - "icon": self._presentation_provider.get_icon( - tenant_id=dify_ctx.tenant_id, - agent_strategy_provider_name=self.node_data.agent_strategy_provider_name, - ), - "agent_strategy": self.node_data.agent_strategy_name, - }, - parameters_for_log=parameters_for_log, - user_id=dify_ctx.user_id, - tenant_id=dify_ctx.tenant_id, - conversation_id=conversation_id, - node_type=self.node_type, - node_id=self._node_id, - node_execution_id=self.id, - ) - except PluginDaemonClientSideError as e: - transform_error = AgentMessageTransformError( - f"Failed to transform agent message: {str(e)}", original_error=e - ) - yield StreamCompletedEvent( - node_run_result=NodeRunResult( - status=WorkflowNodeExecutionStatus.FAILED, - inputs=parameters_for_log, - error=str(transform_error), - ) - ) - - @classmethod - @override - def _extract_variable_selector_to_variable_mapping( - cls, - *, - graph_config: Mapping[str, Any], - node_id: str, - node_data: AgentNodeData, - ) -> Mapping[str, Sequence[str]]: - _ = graph_config # Explicitly mark as unused - result: dict[str, Any] = {} - typed_node_data = node_data - for parameter_name in typed_node_data.agent_parameters: - input = typed_node_data.agent_parameters[parameter_name] - match input.type: - case "mixed" | "constant": - selectors = VariableTemplateParser(str(input.value)).extract_variable_selectors() - for selector in selectors: - result[selector.variable] = selector.value_selector - case "variable": - result[parameter_name] = input.value - - result = {node_id + "." + key: value for key, value in result.items()} - - return result diff --git a/api/core/workflow/nodes/agent/runtime_support.py b/api/core/workflow/nodes/agent/runtime_support.py deleted file mode 100644 index 6ffccd70777e33..00000000000000 --- a/api/core/workflow/nodes/agent/runtime_support.py +++ /dev/null @@ -1,320 +0,0 @@ -from __future__ import annotations - -import json -from collections.abc import Sequence -from typing import Any, cast - -from packaging.version import Version -from pydantic import ValidationError -from sqlalchemy import select -from sqlalchemy.orm import Session - -from core.agent.entities import AgentToolEntity -from core.agent.plugin_entities import AgentStrategyParameter -from core.memory.token_buffer_memory import TokenBufferMemory -from core.model_manager import ModelInstance -from core.plugin.entities.request import InvokeCredentials -from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly -from core.tools.entities.tool_entities import ToolIdentity, ToolParameter, ToolProviderType -from core.tools.tool_manager import ToolManager -from core.workflow.system_variables import SystemVariableKey, get_system_text -from extensions.ext_database import db -from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelType -from graphon.runtime import VariablePool -from graphon.variables.template_resolution import convert_template -from models.model import Conversation - -from .entities import AgentNodeData, AgentOldVersionModelFeatures, ParamsAutoGenerated -from .exceptions import AgentInputTypeError, AgentVariableNotFoundError -from .strategy_protocols import ResolvedAgentStrategy - - -class AgentRuntimeSupport: - def build_parameters( - self, - *, - agent_parameters: Sequence[AgentStrategyParameter], - variable_pool: VariablePool, - node_data: AgentNodeData, - strategy: ResolvedAgentStrategy, - tenant_id: str, - user_id: str, - app_id: str, - invoke_from: Any, - for_log: bool = False, - ) -> dict[str, Any]: - agent_parameters_dictionary = {parameter.name: parameter for parameter in agent_parameters} - - result: dict[str, Any] = {} - for parameter_name in node_data.agent_parameters: - parameter = agent_parameters_dictionary.get(parameter_name) - if not parameter: - result[parameter_name] = None - continue - - agent_input = node_data.agent_parameters[parameter_name] - match agent_input.type: - case "variable": - variable = variable_pool.get(agent_input.value) # type: ignore[arg-type] - if variable is None: - raise AgentVariableNotFoundError(str(agent_input.value)) - parameter_value = variable.value - case "mixed" | "constant": - try: - if not isinstance(agent_input.value, str): - parameter_value = json.dumps(agent_input.value, ensure_ascii=False) - else: - parameter_value = str(agent_input.value) - except TypeError: - parameter_value = str(agent_input.value) - - segment_group = convert_template(variable_pool, parameter_value) - parameter_value = segment_group.log if for_log else segment_group.text - try: - if not isinstance(agent_input.value, str): - parameter_value = json.loads(parameter_value) - except json.JSONDecodeError: - parameter_value = parameter_value - case _: - raise AgentInputTypeError(agent_input.type) - - value = parameter_value - if parameter.type == "array[tools]": - value = cast(list[dict[str, Any]], value) - value = [tool for tool in value if tool.get("enabled", False)] - value = self._filter_mcp_type_tool(strategy, value) - for tool in value: - if "schemas" in tool: - tool.pop("schemas") - parameters = tool.get("parameters", {}) - if all(isinstance(v, dict) for _, v in parameters.items()): - params = {} - for key, param in parameters.items(): - if param.get("auto", ParamsAutoGenerated.OPEN) in ( - ParamsAutoGenerated.CLOSE, - 0, - ): - value_param = param.get("value", {}) - if value_param and value_param.get("type", "") == "variable": - variable_selector = value_param.get("value") - if not variable_selector: - raise ValueError("Variable selector is missing for a variable-type parameter.") - - variable = variable_pool.get(variable_selector) - if variable is None: - raise AgentVariableNotFoundError(str(variable_selector)) - - params[key] = variable.value - else: - params[key] = value_param.get("value", "") if value_param is not None else None - else: - params[key] = None - parameters = params - tool["settings"] = {k: v.get("value", None) for k, v in tool.get("settings", {}).items()} - tool["parameters"] = parameters - - if not for_log: - if parameter.type == "array[tools]": - value = cast(list[dict[str, Any]], value) - tool_value = [] - for tool in value: - provider_type = ToolProviderType(tool.get("type", ToolProviderType.BUILT_IN)) - setting_params = tool.get("settings", {}) - parameters = tool.get("parameters", {}) - manual_input_params = [key for key, value in parameters.items() if value is not None] - - parameters = {**parameters, **setting_params} - entity = AgentToolEntity( - provider_id=tool.get("provider_name", ""), - provider_type=provider_type, - tool_name=tool.get("tool_name", ""), - tool_parameters=parameters, - plugin_unique_identifier=tool.get("plugin_unique_identifier", None), - credential_id=tool.get("credential_id", None), - ) - - extra = tool.get("extra", {}) - - runtime_variable_pool: VariablePool | None = None - if node_data.version != "1" or node_data.tool_node_version is not None: - runtime_variable_pool = variable_pool - tool_runtime = ToolManager.get_agent_tool_runtime( - tenant_id, - app_id, - entity, - user_id, - invoke_from, - runtime_variable_pool, - ) - if tool_runtime.entity.description: - tool_runtime.entity.description.llm = ( - extra.get("description", "") or tool_runtime.entity.description.llm - ) - for tool_runtime_params in tool_runtime.entity.parameters: - tool_runtime_params.form = ( - ToolParameter.ToolParameterForm.FORM - if tool_runtime_params.name in manual_input_params - else tool_runtime_params.form - ) - manual_input_value = {} - if tool_runtime.entity.parameters: - manual_input_value = { - key: value for key, value in parameters.items() if key in manual_input_params - } - runtime_parameters = { - **tool_runtime.runtime.runtime_parameters, - **manual_input_value, - } - tool_value.append( - { - **tool_runtime.entity.model_dump(mode="json"), - "runtime_parameters": runtime_parameters, - "credential_id": tool.get("credential_id", None), - "provider_type": provider_type.value, - } - ) - value = tool_value - if parameter.type == AgentStrategyParameter.AgentStrategyParameterType.MODEL_SELECTOR: - value = cast(dict[str, Any], value) - model_instance, model_schema = self.fetch_model( - tenant_id=tenant_id, - user_id=user_id, - value=value, - ) - history_prompt_messages = [] - if node_data.memory: - memory = self.fetch_memory( - variable_pool=variable_pool, - app_id=app_id, - model_instance=model_instance, - ) - if memory: - prompt_messages = memory.get_history_prompt_messages( - message_limit=node_data.memory.window.size or None - ) - history_prompt_messages = [ - prompt_message.model_dump(mode="json") for prompt_message in prompt_messages - ] - value["history_prompt_messages"] = history_prompt_messages - if model_schema: - model_schema = self._remove_unsupported_model_features_for_old_version(model_schema) - value["entity"] = model_schema.model_dump(mode="json") - # The model selector value from the workflow frontend only - # carries provider/model/mode — it does NOT include - # completion_params. AgentStrategy plugins (cot_agent, - # function_calling) read completion_params to build the - # LLMModelConfig that is backwards-invoked, and some model - # providers raise KeyError('required') when - # completion_params is empty because their parameter_rules - # declare required fields with no default. Populate - # completion_params with the defaults declared in the model - # schema so the plugin daemon always receives a valid set - # of model parameters. - if "completion_params" not in value: - value["completion_params"] = self._extract_default_completion_params(model_schema) - else: - value["entity"] = None - if "completion_params" not in value: - value["completion_params"] = {} - result[parameter_name] = value - - return result - - def build_credentials(self, *, parameters: dict[str, Any]) -> InvokeCredentials: - credentials = InvokeCredentials() - credentials.tool_credentials = {} - for tool in parameters.get("tools", []): - if not tool.get("credential_id"): - continue - try: - identity = ToolIdentity.model_validate(tool.get("identity", {})) - except ValidationError: - continue - credentials.tool_credentials[identity.provider] = tool.get("credential_id", None) - return credentials - - def fetch_memory( - self, - *, - variable_pool: VariablePool, - app_id: str, - model_instance: ModelInstance, - ) -> TokenBufferMemory | None: - conversation_id = get_system_text(variable_pool, SystemVariableKey.CONVERSATION_ID) - if conversation_id is None: - return None - - with Session(db.engine, expire_on_commit=False) 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) - - def fetch_model( - self, - *, - tenant_id: str, - user_id: str, - value: dict[str, Any], - ) -> tuple[ModelInstance, AIModelEntity | None]: - assembly = create_plugin_model_assembly(tenant_id=tenant_id, user_id=user_id) - provider_model_bundle = assembly.provider_manager.get_provider_model_bundle( - tenant_id=tenant_id, - provider=value.get("provider", ""), - model_type=ModelType.LLM, - ) - model_name = value.get("model", "") - model_credentials = provider_model_bundle.configuration.get_current_credentials( - model_type=ModelType.LLM, - model=model_name, - ) - provider_name = provider_model_bundle.configuration.provider.provider - model_type_instance = provider_model_bundle.model_type_instance - model_instance = assembly.model_manager.get_model_instance( - tenant_id=tenant_id, - provider=provider_name, - model_type=ModelType(value.get("model_type", "")), - model=model_name, - ) - model_schema = model_type_instance.get_model_schema(model_name, model_credentials) - return model_instance, model_schema - - @staticmethod - def _remove_unsupported_model_features_for_old_version(model_schema: AIModelEntity) -> AIModelEntity: - if model_schema.features: - for feature in model_schema.features[:]: - try: - AgentOldVersionModelFeatures(feature.value) - except ValueError: - model_schema.features.remove(feature) - return model_schema - - @staticmethod - def _extract_default_completion_params(model_schema: AIModelEntity) -> dict[str, Any]: - """Build a completion_params dict from the model schema's parameter_rules. - - The workflow Agent node's model-selector parameter only stores - provider/model/mode — it never carries completion_params. When the - value is forwarded to the plugin daemon, AgentModelConfig defaults - completion_params to ``{}``, which causes some model providers to fail - because their parameter_rules declare required fields. This helper - collects the ``default`` value of every parameter_rule that has one so - the plugin daemon receives a valid, non-empty set of model parameters. - """ - completion_params: dict[str, Any] = {} - for rule in model_schema.parameter_rules: - if rule.default is not None: - completion_params[rule.name] = rule.default - return completion_params - - @staticmethod - def _filter_mcp_type_tool( - strategy: ResolvedAgentStrategy, - tools: list[dict[str, Any]], - ) -> list[dict[str, Any]]: - meta_version = strategy.meta_version - if meta_version and Version(meta_version) > Version("0.0.1"): - return tools - return [tool for tool in tools if tool.get("type") != ToolProviderType.MCP] diff --git a/api/core/workflow/nodes/agent_v2/agent_node.py b/api/core/workflow/nodes/agent_v2/agent_node.py deleted file mode 100644 index ee4b2e1b1d4544..00000000000000 --- a/api/core/workflow/nodes/agent_v2/agent_node.py +++ /dev/null @@ -1,876 +0,0 @@ -from __future__ import annotations - -import logging -from collections.abc import Generator, Mapping, Sequence -from typing import TYPE_CHECKING, Any, override - -from agenton.compositor import CompositorSessionSnapshot -from dify_agent.protocol import CancelRunRequest - -from clients.agent_backend import ( - AgentBackendAgentMessageDeltaInternalEvent, - AgentBackendDeferredToolCallInternalEvent, - AgentBackendError, - AgentBackendHTTPError, - AgentBackendInternalEventType, - AgentBackendRunCancelledInternalEvent, - AgentBackendRunClient, - AgentBackendRunEventAdapter, - AgentBackendRunFailedInternalEvent, - AgentBackendRunSucceededInternalEvent, - AgentBackendStreamError, - AgentBackendStreamInternalEvent, - AgentBackendTransportError, - AgentBackendValidationError, -) -from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext -from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl -from core.workflow.nodes.human_input.pause_reason import HumanInputRequired -from core.workflow.nodes.human_input.session_binding import default_session_binding -from core.workflow.system_variables import SystemVariableKey, get_system_text -from graphon.entities.pause_reason import HitlRequired, SchedulingPause -from graphon.enums import ( - BuiltinNodeTypes, - ErrorStrategy, - NodeExecutionType, - WorkflowNodeExecutionMetadataKey, - WorkflowNodeExecutionStatus, -) -from graphon.graph_events import NodeRunPauseRequestedEvent -from graphon.node_events import NodeEventBase, NodeRunResult, StreamCompletedEvent -from graphon.nodes.base.node import Node -from graphon.nodes.base.variable_template_parser import VariableTemplateParser -from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig, WorkflowOutputRoutes -from services.agent.prompt_mentions import extract_workflow_node_output_selectors -from services.agent.workspace_service import AgentWorkspaceNotFoundError - -from .ask_human_hitl import AskHumanFormBuildError, build_ask_human_pause_reason -from .ask_human_resume import build_deferred_tool_results, resolve_ask_human_form -from .binding_resolver import WorkflowAgentBindingError, WorkflowAgentBindingResolver -from .entities import DifyAgentNodeData -from .output_adapter import WorkflowAgentOutputAdapter -from .output_failure_orchestrator import ( - FailedOutput, - OutputFailureDecision, - OutputFailureKind, - OutputFailureOrchestrator, -) -from .output_type_checker import OutputTypeCheckOutcome, PerOutputTypeChecker -from .runtime_request_builder import ( - WorkflowAgentRuntimeBuildContext, - WorkflowAgentRuntimeRequestBuilder, - WorkflowAgentRuntimeRequestBuildError, -) -from .session_store import WorkflowAgentSessionScope, WorkflowAgentWorkspaceStore - -if TYPE_CHECKING: - from graphon.entities import GraphInitParams - from graphon.runtime import GraphRuntimeState - -logger = logging.getLogger(__name__) - - -# Stage 4 §5+§7: the terminal events that `_consume_event_stream` may return. -# Stream + started events are filtered out before we yield; transport errors -# are surfaced as a separate StreamCompletedEvent in the second tuple slot. -type _TerminalAgentBackendEvent = ( - AgentBackendRunSucceededInternalEvent - | AgentBackendRunFailedInternalEvent - | AgentBackendRunCancelledInternalEvent - | AgentBackendDeferredToolCallInternalEvent -) - - -class DifyAgentNode(Node[DifyAgentNodeData]): - """Execute a frozen node job and select one workflow exit on success. - - Enabled routing selects a stable route ID through the required ``switch`` - output. Disabled routing remains executable so - graph-owned default values can continue without a selected route; Graphon - still promotes nodes that opt into its failure branch. - """ - - node_type = BuiltinNodeTypes.AGENT - - def __init__( - self, - node_id: str, - data: DifyAgentNodeData, - *, - graph_init_params: GraphInitParams, - graph_runtime_state: GraphRuntimeState, - binding_resolver: WorkflowAgentBindingResolver, - runtime_request_builder: WorkflowAgentRuntimeRequestBuilder, - agent_backend_client: AgentBackendRunClient, - event_adapter: AgentBackendRunEventAdapter, - output_adapter: WorkflowAgentOutputAdapter, - type_checker: PerOutputTypeChecker, - failure_orchestrator: OutputFailureOrchestrator, - session_store: WorkflowAgentWorkspaceStore, - ) -> None: - super().__init__( - node_id=node_id, - data=data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, - ) - # The base node parses the factory's serialized payload into node_data. - if self.node_data.agent_output_routes.enabled and self.error_strategy == ErrorStrategy.DEFAULT_VALUE: - raise ValueError("Output routes do not support the node-level default-value error strategy.") - if self.node_data.agent_output_routes.enabled: - self.execution_type = NodeExecutionType.BRANCH - self._binding_resolver = binding_resolver - self._runtime_request_builder = runtime_request_builder - self._agent_backend_client = agent_backend_client - self._event_adapter = event_adapter - self._output_adapter = output_adapter - self._type_checker = type_checker - self._failure_orchestrator = failure_orchestrator - self._session_store = session_store - - @classmethod - @override - def version(cls) -> str: - return "2" - - @override - def populate_start_event(self, event) -> None: - event.extras["agent_node"] = {"version": "2", "agent_node_kind": self.node_data.agent_node_kind} - - @staticmethod - def _to_graph_pause_reason(reason: HumanInputRequired | SchedulingPause) -> HitlRequired | SchedulingPause: - if isinstance(reason, HumanInputRequired): - return HitlRequired( - session_id=default_session_binding.issue_session_id_for_form(form_id=reason.form_id), - node_id=reason.node_id, - node_title=reason.node_title, - ) - return reason - - @override - def _run(self) -> Generator[NodeEventBase | NodeRunPauseRequestedEvent, None, None]: - inputs: dict[str, Any] = {} - process_data: dict[str, Any] = {} - metadata: dict[str, Any] = { - "agent_backend": { - "status": "not_started", - } - } - try: - yield from self._run_inner(inputs=inputs, process_data=process_data, metadata=metadata) - except Exception as error: - if not process_data: - raise - yield self._failure_event( - inputs=inputs, - process_data=process_data, - metadata=metadata, - error=str(error), - error_type="agent_workflow_node_runtime_error", - ) - - def _run_inner( - self, - *, - inputs: dict[str, Any], - process_data: dict[str, Any], - metadata: dict[str, Any], - ) -> Generator[NodeEventBase | NodeRunPauseRequestedEvent, None, None]: - dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY)) - workflow_id = self.graph_init_params.workflow_id - workflow_run_id = get_system_text( - self.graph_runtime_state.variable_pool, - SystemVariableKey.WORKFLOW_EXECUTION_ID, - ) - # Set on chatflow (advanced-chat) runs; None for a pure workflow run. Lets an - # ask_human form be tagged with its conversation in addition to workflow_run_id. - conversation_id = get_system_text( - self.graph_runtime_state.variable_pool, - SystemVariableKey.CONVERSATION_ID, - ) - - # ──── Setup: resolve binding once + extract declared outputs for stage 4 checks ──── - try: - existing_scope = self._session_store.load_existing_node_execution_scope( - tenant_id=dify_ctx.tenant_id, - app_id=dify_ctx.app_id, - workflow_id=workflow_id, - workflow_run_id=workflow_run_id, - node_id=self._node_id, - node_execution_id=self.execution_id, - conversation_id=conversation_id, - ) - bundle = self._binding_resolver.resolve( - tenant_id=dify_ctx.tenant_id, - app_id=dify_ctx.app_id, - workflow_id=workflow_id, - node_id=self._node_id, - binding_id=existing_scope.workflow_agent_binding_id if existing_scope is not None else None, - snapshot_id=existing_scope.agent_config_snapshot_id if existing_scope is not None else None, - conversation_id=conversation_id, - ) - except WorkflowAgentBindingError as error: - yield self._failure_event( - inputs=inputs, - process_data=process_data, - metadata=metadata, - error=str(error), - error_type=error.error_code, - ) - return - except AgentWorkspaceNotFoundError as error: - yield self._failure_event( - inputs=inputs, - process_data=process_data, - metadata=metadata, - error=str(error), - error_type="agent_workflow_node_runtime_error", - ) - return - - process_data.update( - { - "agent_id": bundle.agent.id, - "agent_config_snapshot_id": bundle.snapshot.id, - "workflow_agent_binding_id": bundle.binding.id, - } - ) - session_scope = existing_scope or WorkflowAgentSessionScope( - tenant_id=dify_ctx.tenant_id, - app_id=dify_ctx.app_id, - workflow_id=workflow_id, - workflow_run_id=workflow_run_id, - node_id=self._node_id, - node_execution_id=self.execution_id, - workflow_agent_binding_id=bundle.binding.id, - agent_id=bundle.agent.id, - agent_config_snapshot_id=bundle.snapshot.id, - conversation_id=conversation_id, - ) - - node_job = WorkflowNodeJobConfig.model_validate(bundle.binding.node_job_config_dict) - node_job.output_routes.validate_for_execution() - custom_outputs = list(node_job.declared_outputs) - outputs_by_name = {output.name: output for output in custom_outputs} - - # ──── ENG-638: resume after a submitted/timed-out ask_human form ──── - # graphon re-executes this _run when the outer workflow resumes. If a - # pending ask_human form is now terminal, thread the human's answer into - # the second Agent run as deferred_tool_results; if it is somehow still - # waiting, re-emit the same pause defensively. - deferred_tool_results = None - stored_session = self._session_store.load_or_create_node_execution_session( - session_scope, - home_snapshot_id=bundle.snapshot.home_snapshot_id, - ) - if stored_session.pending_form_id is not None: - resume_outcome = resolve_ask_human_form( - form_id=stored_session.pending_form_id, - tenant_id=dify_ctx.tenant_id, - node_id=self._node_id, - ) - if resume_outcome is not None and resume_outcome.repause is not None: - yield self._pause_event( - reason=resume_outcome.repause, - inputs=inputs, - process_data=process_data, - metadata=metadata, - ) - return - if ( - resume_outcome is not None - and resume_outcome.deferred_result is not None - and stored_session.pending_tool_call_id is not None - ): - deferred_tool_results = build_deferred_tool_results( - tool_call_id=stored_session.pending_tool_call_id, - result=resume_outcome.deferred_result, - ) - - # ──── Retry loop (Stage 4 §7) ──── - attempt = 0 - while True: - try: - runtime_request = self._runtime_request_builder.build( - WorkflowAgentRuntimeBuildContext( - dify_context=dify_ctx, - workflow_id=workflow_id, - workflow_run_id=workflow_run_id, - node_id=self._node_id, - node_execution_id=self.execution_id, - variable_pool=self.graph_runtime_state.variable_pool, - binding=bundle.binding, - agent=bundle.agent, - snapshot=bundle.snapshot, - binding_id=stored_session.binding_id, - backend_binding_ref=stored_session.backend_binding_ref, - attempt=attempt, - session_snapshot=stored_session.session_snapshot, - deferred_tool_results=deferred_tool_results, - ) - ) - except WorkflowAgentRuntimeRequestBuildError as error: - yield self._failure_event( - inputs=inputs, - process_data=process_data, - metadata=metadata, - error=str(error), - error_type=error.error_code, - ) - return - except Exception as error: - yield self._failure_event( - inputs=inputs, - process_data=process_data, - metadata=metadata, - error=str(error), - error_type="agent_workflow_node_runtime_error", - ) - return - - # Capture inputs only from the first attempt so retry doesn't churn the - # node's "inputs" payload that ends up in the workflow detail view. - if attempt == 0: - inputs["agent_backend_request"] = runtime_request.redacted_request - metadata.clear() - metadata.update(runtime_request.metadata) - metadata["attempt"] = attempt - - try: - create_response = self._agent_backend_client.create_run(runtime_request.request) - except AgentBackendError as error: - yield self._failure_event( - inputs=inputs, - process_data=process_data, - metadata=metadata, - error=str(error), - error_type=self._agent_backend_error_type(error), - ) - return - - metadata["agent_backend"] = { - **dict(metadata.get("agent_backend") or {}), - "run_id": create_response.run_id, - "status": create_response.status, - } - - terminal_event, exhausted = self._consume_event_stream( - create_response.run_id, - inputs=inputs, - process_data=process_data, - metadata=metadata, - ) - # None means no post-exit snapshot was produced; leave the previously stored session snapshot untouched. - if ( - isinstance(terminal_event, AgentBackendRunFailedInternalEvent | AgentBackendRunCancelledInternalEvent) - and terminal_event.session_snapshot is not None - ): - self._save_session_snapshot( - session_scope=session_scope, - binding_id=stored_session.binding_id, - snapshot=terminal_event.session_snapshot, - metadata=metadata, - ) - if exhausted is not None: - # Streaming error / unexpected end — surface immediately without - # retrying because the failure is transport-level. - yield exhausted - return - if terminal_event is None: - yield StreamCompletedEvent( - node_run_result=self._output_adapter.build_stream_exhausted_result( - inputs=inputs, - process_data=process_data, - metadata=metadata, - ) - ) - return - - if isinstance(terminal_event, AgentBackendDeferredToolCallInternalEvent): - # ENG-636: a dify.ask_human deferred call pauses the *outer* - # workflow through the existing HITL form path. Any other deferred - # tool (none today) falls back to a generic scheduling pause. The - # form is built *before* the snapshot is saved so its id can be - # persisted as the pause correlation (ENG-637). - try: - pause_request = build_ask_human_pause_reason( - deferred_tool_call=terminal_event.deferred_tool_call, - node_id=self._node_id, - default_node_title=bundle.agent.name or self._node_id, - workflow_run_id=workflow_run_id, - conversation_id=conversation_id, - contacts=AgentSoulConfig.model_validate(bundle.snapshot.config_snapshot_dict).human.contacts, - repository=self._build_human_input_form_repository( - dify_ctx=dify_ctx, workflow_run_id=workflow_run_id - ), - ) - except AskHumanFormBuildError as error: - yield self._failure_event( - inputs=inputs, - process_data=process_data, - metadata=metadata, - error=str(error), - error_type="agent_ask_human_form_build_error", - ) - return - - # ENG-637: persist the awaiting-form id + deferred tool_call id - # next to the snapshot so the resumed node can rebuild - # deferred_tool_results from the submitted form. - pending_form_id: str | None = None - pending_tool_call_id: str | None = None - pause_reason: HumanInputRequired | SchedulingPause - if pause_request is not None: - pending_form_id = pause_request.form_id - pending_tool_call_id = terminal_event.deferred_tool_call.tool_call_id - pause_reason = pause_request - else: - pause_reason = SchedulingPause( - message=terminal_event.message - or "Agent backend run requested workflow pause for external input." - ) - self._save_session_snapshot( - session_scope=session_scope, - binding_id=stored_session.binding_id, - snapshot=terminal_event.session_snapshot, - metadata=metadata, - pending_form_id=pending_form_id, - pending_tool_call_id=pending_tool_call_id, - ) - yield self._pause_event( - reason=pause_reason, - inputs=inputs, - process_data=process_data, - metadata=metadata, - ) - return - - # A failed attempt does not retire the product-owned Binding. The - # Workflow Run terminal lifecycle event owns that transition. - if not isinstance(terminal_event, AgentBackendRunSucceededInternalEvent): - yield StreamCompletedEvent( - node_run_result=self._output_adapter.build_failure_result( - event=terminal_event, - inputs=inputs, - process_data=process_data, - metadata=metadata, - ) - ) - return - - self._save_session_snapshot( - session_scope=session_scope, - binding_id=stored_session.binding_id, - snapshot=terminal_event.session_snapshot, - metadata=metadata, - ) - - success_event = terminal_event - if custom_outputs: - # ──── Stage 4: per-output type check ──── - type_check = self._type_checker.check( - declared_outputs=custom_outputs, - raw_output=terminal_event.output, - tenant_id=dify_ctx.tenant_id, - ) - self._record_type_check_metadata(metadata, type_check) - - if type_check.has_failures: - # ──── Stage 4: orchestrate retry / default / fail ──── - failures = [ - FailedOutput( - declared=outputs_by_name[result.name], - failure_kind=OutputFailureKind.TYPE_CHECK, - reason=result.reason, - ) - for result in type_check.failures - if result.name in outputs_by_name - ] - outcome = self._failure_orchestrator.decide(failures=failures, current_attempt=attempt) - metadata["output_failure_decision"] = outcome.decision.value - metadata["output_failure_reason"] = outcome.primary_reason - - if outcome.decision == OutputFailureDecision.RETRY: - attempt = outcome.next_attempt - continue - - if outcome.decision == OutputFailureDecision.USE_DEFAULT: - success_event = self._patch_event_with_defaults(terminal_event, outcome.per_output_actions) - else: - error_type = ( - "output_type_check_failed_fail_branch" - if outcome.decision == OutputFailureDecision.TAKE_FAIL_BRANCH - else "output_type_check_failed" - ) - yield self._failure_event( - inputs=inputs, - process_data=process_data, - metadata=metadata, - error=outcome.primary_reason, - error_type=error_type, - ) - return - - # Select only after this attempt's custom outputs have passed. Failed - # route output uses the node's normal retry / failure-branch handling. - routes = node_job.output_routes - edge_source_handle = "source" - if routes.enabled: - selected = success_event.output.get("switch") if isinstance(success_event.output, dict) else None - if not isinstance(selected, str) or selected not in {route.id for route in routes.routes}: - yield self._failure_event( - inputs=inputs, - process_data=process_data, - metadata=metadata, - error="Agent output switch must select one configured output route ID.", - error_type="output_route_selection_failed", - ) - return - edge_source_handle = selected - - yield StreamCompletedEvent( - node_run_result=self._output_adapter.build_success_result( - event=success_event, - edge_source_handle=edge_source_handle, - inputs=inputs, - process_data=process_data, - metadata=metadata, - declared_outputs=custom_outputs, - ) - ) - return - - def _consume_event_stream( - self, - run_id: str, - *, - inputs: dict[str, Any], - process_data: dict[str, Any], - metadata: dict[str, Any], - ) -> tuple[ - _TerminalAgentBackendEvent | None, - StreamCompletedEvent | None, - ]: - """Consume the SSE stream for one Agent backend run. - - Returns a 2-tuple ``(terminal_event, transport_failure)``: - - ``terminal_event``: the first non-stream/non-started internal event, - or ``None`` if the stream ended without one. - - ``transport_failure``: a populated ``StreamCompletedEvent`` when the - stream itself errored (backend/HTTP/protocol fault). A cancellation - terminal may accompany it so the caller can persist the final session - snapshot while preserving the original transport failure. - """ - stream_event_count = 0 - last_event_id: str | None = None - try: - for public_event in self._agent_backend_client.stream_events( - run_id, - should_stop=self._is_graph_aborted, - ): - stream_event_count += 1 - if public_event.id is not None: - last_event_id = public_event.id - for internal_event in self._event_adapter.adapt(public_event): - if internal_event.type == AgentBackendInternalEventType.RUN_STARTED: - continue - if internal_event.type == AgentBackendInternalEventType.STREAM_EVENT: - if isinstance(internal_event, AgentBackendStreamInternalEvent): - self._record_stream_metadata(metadata, internal_event) - continue - if internal_event.type == AgentBackendInternalEventType.AGENT_MESSAGE_DELTA: - if isinstance(internal_event, AgentBackendAgentMessageDeltaInternalEvent): - self._record_agent_message_delta_metadata(metadata, internal_event) - continue - metadata["agent_backend"] = { - **dict(metadata.get("agent_backend") or {}), - "stream_event_count": stream_event_count, - } - # Narrow to the known terminal event types before returning - # to the caller. Deferred-tool events are terminal on the - # Dify Agent wire, then converted into workflow pause locally. - if isinstance( - internal_event, - AgentBackendRunSucceededInternalEvent - | AgentBackendRunFailedInternalEvent - | AgentBackendRunCancelledInternalEvent - | AgentBackendDeferredToolCallInternalEvent, - ): - return internal_event, None - cancellation = self._cancel_backend_run( - run_id, - reason="unexpected_event", - after=last_event_id, - ) - return cancellation, self._failure_event( - inputs=inputs, - process_data=process_data, - metadata=metadata, - error=f"Unexpected internal event type {internal_event.type!r}", - error_type="agent_backend_stream_error", - ) - except AgentBackendError as error: - cancellation = self._cancel_backend_run( - run_id, - reason=self._stream_stop_reason(), - after=last_event_id, - ) - return cancellation, self._failure_event( - inputs=inputs, - process_data=process_data, - metadata=metadata, - error=str(error), - error_type=self._agent_backend_error_type(error), - ) - except Exception as error: - cancellation = self._cancel_backend_run( - run_id, - reason=self._stream_stop_reason(), - after=last_event_id, - ) - return cancellation, self._failure_event( - inputs=inputs, - process_data=process_data, - metadata=metadata, - error=str(error), - error_type="agent_backend_stream_error", - ) - - cancellation = self._cancel_backend_run( - run_id, - reason=self._stream_stop_reason() if self._is_graph_aborted() else "stream_ended_without_terminal_event", - after=last_event_id, - ) - return cancellation, None - - def _is_graph_aborted(self) -> bool: - """Let Agent SSE consumption observe GraphEngine's cooperative abort state.""" - try: - return self.graph_runtime_state.graph_execution.aborted - except (AttributeError, RuntimeError): - return False - - def _stream_stop_reason(self) -> str: - return "workflow_graph_aborted" if self._is_graph_aborted() else "event_stream_failed" - - def _cancel_backend_run( - self, - run_id: str, - *, - reason: str, - after: str | None, - ) -> AgentBackendRunCancelledInternalEvent | None: - try: - public_event = self._agent_backend_client.cancel_run_and_wait( - run_id, - CancelRunRequest(reason=reason, message="Workflow Agent event consumption stopped"), - after=after, - ) - for internal_event in self._event_adapter.adapt(public_event): - if isinstance(internal_event, AgentBackendRunCancelledInternalEvent): - return internal_event - except Exception: - logger.warning("Failed to finish cancelling Workflow Agent backend run: run_id=%s", run_id, exc_info=True) - return None - - @staticmethod - def _record_type_check_metadata(metadata: dict[str, Any], outcome: OutputTypeCheckOutcome) -> None: - # Surface enough detail in metadata for Inspector / debug logs without - # leaking the raw failing values (which may be sensitive). - metadata["output_type_check"] = { - "passed": not outcome.has_failures, - "results": [ - { - "name": r.name, - "type": r.declared_type.value, - "status": r.status.value, - "reason": r.reason, - } - for r in outcome.results - ], - } - - def _build_human_input_form_repository( - self, - *, - dify_ctx: DifyRunContext, - workflow_run_id: str | None, - ) -> HumanInputFormRepository: - """Construct the existing HITL form repository for ask_human form creation. - - Mirrors the Human Input node's repository wiring (``node_runtime``) so the - ask_human form shares the same delivery/debug/console behavior: a - submission actor is only attributed for debugger/explore surfaces. - """ - invoke_source = dify_ctx.invoke_from.value - return HumanInputFormRepositoryImpl( - tenant_id=dify_ctx.tenant_id, - app_id=dify_ctx.app_id, - workflow_execution_id=workflow_run_id, - invoke_source=invoke_source, - submission_actor_id=dify_ctx.user_id if invoke_source in {"debugger", "explore"} else None, - ) - - def _save_session_snapshot( - self, - *, - session_scope: WorkflowAgentSessionScope, - binding_id: str, - snapshot: CompositorSessionSnapshot | None, - metadata: dict[str, Any], - pending_form_id: str | None = None, - pending_tool_call_id: str | None = None, - ) -> None: - try: - self._session_store.save_active_snapshot( - scope=session_scope, - binding_id=binding_id, - snapshot=snapshot, - pending_form_id=pending_form_id, - pending_tool_call_id=pending_tool_call_id, - ) - agent_backend = dict(metadata.get("agent_backend") or {}) - agent_backend["session_snapshot_persisted"] = snapshot is not None - metadata["agent_backend"] = agent_backend - except Exception: - logger.warning( - "Failed to persist workflow Agent Binding session snapshot: " - "tenant_id=%s workflow_run_id=%s node_id=%s binding_id=%s agent_id=%s", - session_scope.tenant_id, - session_scope.workflow_run_id, - session_scope.node_id, - session_scope.workflow_agent_binding_id, - session_scope.agent_id, - exc_info=True, - ) - agent_backend = dict(metadata.get("agent_backend") or {}) - agent_backend["session_snapshot_persisted"] = False - agent_backend["session_snapshot_persist_error"] = "workflow_agent_workspace_store_error" - metadata["agent_backend"] = agent_backend - - @staticmethod - def _patch_event_with_defaults( - event: AgentBackendRunSucceededInternalEvent, - per_output_actions: Mapping[str, Any], - ) -> AgentBackendRunSucceededInternalEvent: - """Merge USE_DEFAULT replacements into the success event's output dict. - - The event is a frozen dataclass / Pydantic model; we copy with the - replacements applied so downstream code (output_adapter normalize) sees - the patched payload. - """ - if not per_output_actions: - return event - original = event.output if isinstance(event.output, Mapping) else {} - patched_output: dict[str, Any] = dict(original) - patched_output.update(per_output_actions) - return event.model_copy(update={"output": patched_output}) - - @staticmethod - def _failure_event( - *, - inputs: dict[str, Any], - process_data: dict[str, Any], - metadata: dict[str, Any], - error: str, - error_type: str, - ) -> StreamCompletedEvent: - return StreamCompletedEvent( - node_run_result=NodeRunResult( - status=WorkflowNodeExecutionStatus.FAILED, - inputs=inputs, - process_data=process_data, - metadata={WorkflowNodeExecutionMetadataKey.AGENT_LOG: metadata}, - outputs={}, - error=error, - error_type=error_type, - ) - ) - - def _pause_event( - self, - *, - reason: HumanInputRequired | SchedulingPause, - inputs: dict[str, Any], - process_data: dict[str, Any], - metadata: dict[str, Any], - ) -> NodeRunPauseRequestedEvent: - return NodeRunPauseRequestedEvent( - id=self.execution_id, - node_id=self._node_id, - node_type=self.node_type, - node_run_result=NodeRunResult( - status=WorkflowNodeExecutionStatus.PAUSED, - inputs=inputs, - process_data=process_data, - metadata={WorkflowNodeExecutionMetadataKey.AGENT_LOG: metadata}, - ), - reason=self._to_graph_pause_reason(reason), - ) - - @staticmethod - def _agent_backend_error_type(error: AgentBackendError) -> str: - if isinstance(error, AgentBackendValidationError): - return "agent_backend_validation_error" - if isinstance(error, AgentBackendHTTPError): - return "agent_backend_http_error" - if isinstance(error, AgentBackendStreamError): - return "agent_backend_stream_error" - if isinstance(error, AgentBackendTransportError): - return "agent_backend_transport_error" - return "agent_backend_error" - - @staticmethod - def _record_stream_metadata(metadata: dict[str, Any], event: AgentBackendStreamInternalEvent) -> None: - agent_backend = dict(metadata.get("agent_backend") or {}) - agent_backend["last_stream_event_id"] = event.source_event_id - if event.event_kind: - agent_backend["last_stream_event_kind"] = event.event_kind - if isinstance(event.data, Mapping): - usage = event.data.get("usage") or event.data.get("model_usage") - if isinstance(usage, Mapping): - agent_backend["usage"] = dict(usage) - metadata["agent_backend"] = agent_backend - - @staticmethod - def _record_agent_message_delta_metadata( - metadata: dict[str, Any], event: AgentBackendAgentMessageDeltaInternalEvent - ) -> None: - agent_backend = dict(metadata.get("agent_backend") or {}) - agent_backend["agent_message_delta_count"] = int(agent_backend.get("agent_message_delta_count") or 0) + 1 - agent_backend["agent_message_delta_length"] = int(agent_backend.get("agent_message_delta_length") or 0) + len( - event.delta - ) - metadata["agent_backend"] = agent_backend - - @classmethod - @override - def _extract_variable_selector_to_variable_mapping( - cls, - *, - graph_config: Mapping[str, Any], - node_id: str, - node_data: DifyAgentNodeData, - ) -> Mapping[str, Sequence[str]]: - """Reuse frontend workflow-marker parsing for graph variable loading. - - Task mentions use the publish parser. Enabled route conditions use - Graphon template selectors, including environment and system variables; - disabled jobs do not load their saved conditions. - """ - del graph_config - - if isinstance(node_data, Mapping): - task = node_data.get("agent_task", "") - routes = WorkflowOutputRoutes.model_validate(node_data.get("agent_output_routes", {})) - else: - task = node_data.agent_task - routes = node_data.agent_output_routes - selectors = list(extract_workflow_node_output_selectors(task)) - if routes.enabled: - for route in routes.routes: - selectors.extend( - tuple(selector.value_selector) - for selector in VariableTemplateParser(template=route.name).extract_variable_selectors() - ) - return {f"{node_id}.{'.'.join(selector)}": list(selector) for selector in selectors} diff --git a/api/core/workflow/nodes/agent_v2/binding_resolver.py b/api/core/workflow/nodes/agent_v2/binding_resolver.py deleted file mode 100644 index 4ea47adc5f8fbc..00000000000000 --- a/api/core/workflow/nodes/agent_v2/binding_resolver.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from sqlalchemy import select - -from core.agent.publish_visibility import workflow_callable_active_snapshot_filter -from core.db.session_factory import session_factory -from core.workflow.nodes.agent_v2.session_store import ( - WorkflowAgentWorkspaceStore, - resolve_workflow_agent_workspace_owner_scope, -) -from models.agent import ( - Agent, - AgentConfigSnapshot, - AgentConfigVersionKind, - AgentScope, - AgentStatus, - WorkflowAgentBindingType, - WorkflowAgentNodeBinding, -) - - -class WorkflowAgentBindingError(Exception): - error_code: str - - def __init__(self, error_code: str, message: str) -> None: - self.error_code = error_code - super().__init__(message) - - -@dataclass(frozen=True, slots=True) -class WorkflowAgentBindingBundle: - binding: WorkflowAgentNodeBinding - agent: Agent - snapshot: AgentConfigSnapshot - - -class WorkflowAgentBindingResolver: - """Resolve an owned binding without allowing unpublished roster snapshots to run.""" - - def resolve( - self, - *, - tenant_id: str, - app_id: str, - workflow_id: str, - node_id: str, - binding_id: str | None = None, - snapshot_id: str | None = None, - conversation_id: str | None = None, - ) -> WorkflowAgentBindingBundle: - """Resolve the generation pinned by an execution or a Chatflow conversation participant.""" - - if (binding_id is None) != (snapshot_id is None): - raise WorkflowAgentBindingError( - "agent_binding_generation_invalid", - "Workflow Agent binding and config snapshot must be pinned together.", - ) - - with session_factory.create_session() as session: - binding_stmt = select(WorkflowAgentNodeBinding).where( - WorkflowAgentNodeBinding.tenant_id == tenant_id, - WorkflowAgentNodeBinding.app_id == app_id, - WorkflowAgentNodeBinding.workflow_id == workflow_id, - WorkflowAgentNodeBinding.node_id == node_id, - ) - if binding_id is not None: - binding_stmt = binding_stmt.where(WorkflowAgentNodeBinding.id == binding_id) - binding = session.scalar(binding_stmt.limit(1)) - if binding is None: - raise WorkflowAgentBindingError( - "agent_binding_not_found", - f"Workflow Agent binding not found for node {node_id}.", - ) - if binding.agent_id is None: - raise WorkflowAgentBindingError("agent_not_available", "Workflow Agent binding has no agent.") - - agent_stmt = select(Agent).where( - Agent.tenant_id == tenant_id, - Agent.id == binding.agent_id, - ) - if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT: - agent_stmt = agent_stmt.where( - Agent.scope == AgentScope.ROSTER, - workflow_callable_active_snapshot_filter(), - ) - agent = session.scalar(agent_stmt.limit(1)) - if agent is None or agent.status == AgentStatus.ARCHIVED: - raise WorkflowAgentBindingError( - "agent_not_available", - f"Agent {binding.agent_id} is not available or has not been published.", - ) - - # A new node execution in the same conversation must keep its participant's - # config/Home generation even after the roster Agent publishes a new version. - if snapshot_id is None and conversation_id: - participant = WorkflowAgentWorkspaceStore.load_active_participant( - session=session, - scope=resolve_workflow_agent_workspace_owner_scope( - tenant_id=tenant_id, - app_id=app_id, - conversation_id=conversation_id, - workflow_run_id=None, - node_id=node_id, - workflow_agent_binding_id=binding.id, - ), - agent_id=agent.id, - ) - if participant is not None: - if participant.agent_config_version_kind != AgentConfigVersionKind.SNAPSHOT: - raise WorkflowAgentBindingError( - "agent_binding_generation_invalid", - "Chatflow Agent participant must reference a config snapshot.", - ) - snapshot_id = participant.agent_config_version_id - - effective_snapshot_id = ( - ( - agent.active_config_snapshot_id - if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT - else binding.current_snapshot_id - ) - if snapshot_id is None - else snapshot_id - ) - if effective_snapshot_id is None: - raise WorkflowAgentBindingError( - "agent_config_snapshot_not_found", - "Workflow Agent binding has no current config snapshot.", - ) - - snapshot = session.scalar( - select(AgentConfigSnapshot) - .where( - AgentConfigSnapshot.tenant_id == tenant_id, - AgentConfigSnapshot.agent_id == agent.id, - AgentConfigSnapshot.id == effective_snapshot_id, - ) - .limit(1) - ) - if snapshot is None: - raise WorkflowAgentBindingError( - "agent_config_snapshot_not_found", - f"Agent config snapshot {effective_snapshot_id} not found.", - ) - - session.expunge(binding) - session.expunge(agent) - session.expunge(snapshot) - return WorkflowAgentBindingBundle(binding=binding, agent=agent, snapshot=snapshot) diff --git a/api/core/workflow/nodes/agent_v2/dify_tools_builder.py b/api/core/workflow/nodes/agent_v2/dify_tools_builder.py deleted file mode 100644 index 7ae59b32bf024e..00000000000000 --- a/api/core/workflow/nodes/agent_v2/dify_tools_builder.py +++ /dev/null @@ -1,570 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any, Final, Literal, Protocol - -from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolProviderType, DifyCoreToolsLayerConfig -from dify_agent.layers.dify_plugin import ( - DifyPluginCredentialValue, - DifyPluginToolConfig, - DifyPluginToolCredentialType, - DifyPluginToolParameter, - DifyPluginToolParameterForm, - DifyPluginToolParameterType, - DifyPluginToolsLayerConfig, -) -from sqlalchemy import select - -from core.agent.entities import AgentToolEntity -from core.app.entities.app_invoke_entities import InvokeFrom -from core.plugin.provider_identity import normalize_plugin_daemon_provider_identity -from core.tools.__base.tool import Tool -from core.tools.entities.tool_entities import ToolProviderType -from core.tools.errors import ToolProviderCredentialValidationError, ToolProviderNotFoundError -from core.tools.tool_manager import ToolManager -from core.tools.workflow_as_tool.provider import WorkflowToolProviderController -from extensions.ext_database import db -from models.agent_config_entities import AgentSoulDifyToolConfig, AgentSoulToolsConfig -from models.provider_ids import ToolProviderID -from models.tools import WorkflowToolProvider -from services.tools.legacy_mcp_tools_manage_service import MCPToolManageService - -_CORE_TOOL_PROVIDER_TYPES: Final[dict[ToolProviderType, DifyCoreToolProviderType]] = { - ToolProviderType.BUILT_IN: "builtin", - ToolProviderType.API: "api", - ToolProviderType.WORKFLOW: "workflow", - ToolProviderType.MCP: "mcp", -} - - -class WorkflowAgentDifyToolsBuildError(ValueError): - """Raised when Agent Soul tools cannot be prepared for Agent backend.""" - - def __init__(self, error_code: str, message: str) -> None: - self.error_code = error_code - super().__init__(message) - - -class AgentToolRuntimeProvider(Protocol): - def get_agent_tool_runtime( - self, - tenant_id: str, - app_id: str, - agent_tool: AgentToolEntity, - user_id: str | None = None, - invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, - variable_pool: Any | None = None, - allow_file_parameters: bool = False, - use_default_for_missing_form_parameters: bool = False, - ) -> Tool: ... - - -class ProviderToolsLister(Protocol): - def __call__( - self, - *, - tenant_id: str, - provider_type: ToolProviderType, - provider_id: str, - ) -> list[str]: ... - - -class MCPProviderIDResolver(Protocol): - def __call__(self, *, tenant_id: str, provider_id: str) -> str: ... - - -@dataclass(frozen=True, slots=True) -class WorkflowAgentToolLayers: - plugin_tools: DifyPluginToolsLayerConfig | None = None - core_tools: DifyCoreToolsLayerConfig | None = None - - def exposed_tool_names(self) -> list[str]: - names: list[str] = [] - if self.plugin_tools is not None: - names.extend(tool.name or tool.tool_name for tool in self.plugin_tools.tools) - if self.core_tools is not None: - names.extend(tool.name or tool.tool_name for tool in self.core_tools.tools) - return names - - -class WorkflowAgentDifyToolLayersBuilder(Protocol): - def build_layers( - self, - *, - tenant_id: str, - app_id: str, - user_id: str | None, - tools: AgentSoulToolsConfig, - invoke_from: InvokeFrom, - ) -> WorkflowAgentToolLayers: ... - - -def _list_provider_tool_names( - *, - tenant_id: str, - provider_type: ToolProviderType, - provider_id: str, -) -> list[str]: - """Tool names a provider currently declares for provider-level Agent entries.""" - match provider_type: - case ToolProviderType.PLUGIN: - plugin_provider = ToolManager.get_plugin_provider(provider_id, tenant_id) - return [tool.entity.identity.name for tool in plugin_provider.get_tools() or []] - case ToolProviderType.BUILT_IN: - builtin_provider = ToolManager.get_builtin_provider(provider_id, tenant_id) - return [tool.entity.identity.name for tool in builtin_provider.get_tools() or []] - case ToolProviderType.API: - api_provider, _ = ToolManager.get_api_provider_controller(tenant_id, provider_id) - return [tool.entity.identity.name for tool in api_provider.get_tools(tenant_id) or []] - case ToolProviderType.WORKFLOW: - db_provider = db.session.scalar( - select(WorkflowToolProvider) - .where( - WorkflowToolProvider.id == provider_id, - WorkflowToolProvider.tenant_id == tenant_id, - ) - .limit(1) - ) - if db_provider is None: - raise ToolProviderNotFoundError(f"workflow provider {provider_id} not found") - workflow_provider = WorkflowToolProviderController.from_db(db_provider) - return [tool.entity.identity.name for tool in workflow_provider.get_tools(tenant_id) or []] - case ToolProviderType.MCP: - mcp_provider = ToolManager.get_mcp_provider_controller(tenant_id, provider_id) - return [tool.entity.identity.name for tool in mcp_provider.get_tools() or []] - case _: - raise ToolProviderNotFoundError(f"provider type {provider_type.value} not found") - - -def _resolve_mcp_provider_id(*, tenant_id: str, provider_id: str) -> str: - """Normalize a persisted MCP provider reference to the runtime-facing server identifier.""" - service = MCPToolManageService(session=db.session()) - try: - return service.get_provider_entity_by_persisted_reference( - id_or_server_identifier=provider_id, tenant_id=tenant_id - ).server_identifier - except ValueError as exc: - raise ToolProviderNotFoundError(f"mcp provider {provider_id} not found") from exc - - -class WorkflowAgentDifyToolsBuilder: - """Prepare Agent Soul Dify tools for Agent backend run-layer configs. - - Plugin tools keep their existing direct daemon path. Core-routed tools - (`builtin`/`api`/`workflow`/`mcp`) are emitted as `dify.core.tools`. - """ - - def __init__( - self, - *, - tool_runtime_provider: AgentToolRuntimeProvider | None = None, - provider_tools_lister: ProviderToolsLister | None = None, - mcp_provider_id_resolver: MCPProviderIDResolver | None = None, - ) -> None: - self._tool_runtime_provider = tool_runtime_provider or ToolManager - self._provider_tools_lister = provider_tools_lister or _list_provider_tool_names - self._mcp_provider_id_resolver = mcp_provider_id_resolver or _resolve_mcp_provider_id - - def build_layers( - self, - *, - tenant_id: str, - app_id: str, - user_id: str | None, - tools: AgentSoulToolsConfig, - invoke_from: InvokeFrom, - ) -> WorkflowAgentToolLayers: - """Resolve user-selected Dify tools into direct/core Agent backend DTOs. - - `invoke_from` is the real runtime caller category (DEBUGGER for a - Composer test run, SERVICE_API / WEB_APP for a published run). It must - be threaded through to `ToolManager` so credential quotas, rate limits, - and audit tags match the actual call site. - """ - enabled_tools = [tool for tool in tools.dify_tools if tool.enabled] - if not enabled_tools: - return WorkflowAgentToolLayers() - - prepared_plugin: list[DifyPluginToolConfig] = [] - prepared_core: list[DifyCoreToolConfig] = [] - seen_names: set[str] = set() - - for tool_config in self._expand_provider_entries(tenant_id=tenant_id, enabled_tools=enabled_tools): - normalized_tool_config = self._normalized_tool_config(tenant_id=tenant_id, tool_config=tool_config) - destination = self._tool_layer_destination(normalized_tool_config) - exposed_name = self._exposed_tool_name(normalized_tool_config) - if exposed_name in seen_names: - raise WorkflowAgentDifyToolsBuildError( - "agent_tool_name_duplicated", - f"Duplicate Dify Tool name {exposed_name!r}.", - ) - seen_names.add(exposed_name) - - agent_tool = self._to_agent_tool_entity(normalized_tool_config) - tool_runtime = self._fetch_tool_runtime( - tenant_id=tenant_id, - app_id=app_id, - user_id=user_id, - agent_tool=agent_tool, - invoke_from=invoke_from, - tool_config=normalized_tool_config, - ) - - if destination == "plugin": - prepared_plugin.append( - self._to_plugin_backend_tool_config(normalized_tool_config, tool_runtime, exposed_name) - ) - else: - prepared_core.append( - self._to_core_backend_tool_config(normalized_tool_config, tool_runtime, exposed_name) - ) - - return WorkflowAgentToolLayers( - plugin_tools=DifyPluginToolsLayerConfig(tools=prepared_plugin) if prepared_plugin else None, - core_tools=DifyCoreToolsLayerConfig(tools=prepared_core) if prepared_core else None, - ) - - def _expand_provider_entries( - self, - *, - tenant_id: str, - enabled_tools: list[AgentSoulDifyToolConfig], - ) -> list[AgentSoulDifyToolConfig]: - """Expand provider-level entries (`tool_name` omitted = all tools).""" - explicit_by_provider: dict[tuple[ToolProviderType, str], set[str]] = {} - for tool_config in enabled_tools: - if tool_config.tool_name is not None: - explicit_by_provider.setdefault(self._provider_key(tool_config), set()).add(tool_config.tool_name) - - expanded: list[AgentSoulDifyToolConfig] = [] - for tool_config in enabled_tools: - if tool_config.tool_name is not None: - expanded.append(tool_config) - continue - provider_type = tool_config.provider_type - provider_id = self._provider_id(tool_config) - try: - tool_names = self._provider_declared_tool_names( - tenant_id=tenant_id, - provider_type=provider_type, - provider_id=provider_id, - ) - except ToolProviderNotFoundError as exc: - raise WorkflowAgentDifyToolsBuildError( - "agent_tool_declaration_not_found", - f"Dify Tool provider {provider_id!r} declaration not found: {exc}", - ) from exc - if not tool_names: - raise WorkflowAgentDifyToolsBuildError( - "agent_tool_declaration_not_found", - f"Dify Tool provider {provider_id!r} declares no tools.", - ) - already_explicit = explicit_by_provider.get(self._provider_key(tool_config), set()) - for tool_name in tool_names: - if tool_name in already_explicit: - continue - expanded.append(tool_config.model_copy(update={"tool_name": tool_name, "runtime_parameters": {}})) - return expanded - - def _provider_declared_tool_names( - self, - *, - tenant_id: str, - provider_type: ToolProviderType, - provider_id: str, - ) -> list[str]: - return self._provider_tools_lister( - tenant_id=tenant_id, - provider_type=provider_type, - provider_id=provider_id, - ) - - def _normalized_tool_config( - self, - *, - tenant_id: str, - tool_config: AgentSoulDifyToolConfig, - ) -> AgentSoulDifyToolConfig: - if tool_config.provider_type is not ToolProviderType.MCP: - return tool_config - provider_id = self._mcp_provider_id_resolver(tenant_id=tenant_id, provider_id=self._provider_id(tool_config)) - return tool_config.model_copy(update={"provider_id": provider_id, "plugin_id": None, "provider": None}) - - def _fetch_tool_runtime( - self, - *, - tenant_id: str, - app_id: str, - user_id: str | None, - agent_tool: AgentToolEntity, - invoke_from: InvokeFrom, - tool_config: AgentSoulDifyToolConfig, - ) -> Tool: - """Resolve the API-side `Tool` runtime and map fetch errors to stable codes.""" - try: - return self._tool_runtime_provider.get_agent_tool_runtime( - tenant_id=tenant_id, - app_id=app_id, - agent_tool=agent_tool, - user_id=user_id, - invoke_from=invoke_from, - variable_pool=None, - allow_file_parameters=True, - use_default_for_missing_form_parameters=True, - ) - except ToolProviderNotFoundError as exc: - raise WorkflowAgentDifyToolsBuildError( - "agent_tool_declaration_not_found", - f"Dify Tool {tool_config.tool_name!r} declaration not found: {exc}", - ) from exc - except ToolProviderCredentialValidationError as exc: - raise WorkflowAgentDifyToolsBuildError( - "agent_tool_credential_invalid", - f"Dify Tool {tool_config.tool_name!r} credential validation failed: {exc}", - ) from exc - except ValueError as exc: - raise WorkflowAgentDifyToolsBuildError( - "agent_tool_config_invalid", - f"Dify Tool {tool_config.tool_name!r} runtime construction failed: {exc}", - ) from exc - - @staticmethod - def _to_agent_tool_entity(tool_config: AgentSoulDifyToolConfig) -> AgentToolEntity: - assert tool_config.tool_name is not None - return AgentToolEntity( - provider_type=tool_config.provider_type, - provider_id=WorkflowAgentDifyToolsBuilder._provider_id(tool_config), - tool_name=tool_config.tool_name, - tool_parameters=dict(tool_config.runtime_parameters), - credential_id=tool_config.credential_ref.id if tool_config.credential_ref else None, - ) - - @staticmethod - def _provider_id(tool_config: AgentSoulDifyToolConfig) -> str: - if tool_config.provider_id: - return tool_config.provider_id - assert tool_config.plugin_id is not None - assert tool_config.provider is not None - return f"{tool_config.plugin_id}/{tool_config.provider}" - - @staticmethod - def _provider_key(tool_config: AgentSoulDifyToolConfig) -> tuple[ToolProviderType, str]: - return (tool_config.provider_type, WorkflowAgentDifyToolsBuilder._provider_id(tool_config)) - - @staticmethod - def _tool_layer_destination(tool_config: AgentSoulDifyToolConfig) -> Literal["plugin", "core"]: - provider_type = tool_config.provider_type - if provider_type is ToolProviderType.PLUGIN or ( - provider_type is ToolProviderType.BUILT_IN and _is_plugin_provider_id(tool_config.provider_id) - ): - return "plugin" - if provider_type in _CORE_TOOL_PROVIDER_TYPES: - return "core" - if provider_type is ToolProviderType.DATASET_RETRIEVAL: - raise WorkflowAgentDifyToolsBuildError( - "agent_tool_provider_not_supported", - "dataset-retrieval remains on the knowledge path and is not supported in Agent tool layers.", - ) - raise WorkflowAgentDifyToolsBuildError( - "agent_tool_provider_not_supported", - f"Dify Tool provider type {provider_type.value!r} is not supported in Agent tool layers.", - ) - - @staticmethod - def _exposed_tool_name(tool_config: AgentSoulDifyToolConfig) -> str: - assert tool_config.tool_name is not None - return tool_config.tool_name - - def _to_plugin_backend_tool_config( - self, - tool_config: AgentSoulDifyToolConfig, - tool_runtime: Tool, - exposed_name: str, - ) -> DifyPluginToolConfig: - runtime = tool_runtime.runtime - if runtime is None: - raise WorkflowAgentDifyToolsBuildError( - "agent_tool_config_invalid", - f"Dify Tool {tool_config.tool_name!r} has no runtime.", - ) - - provider_id = ToolProviderID(self._provider_id(tool_config)) - plugin_id, provider = normalize_plugin_daemon_provider_identity(provider_id, tool_config.plugin_id) - parameters = self._prepared_parameters(tool_runtime) - runtime_parameters = self._runtime_parameters(tool_runtime, parameters) - description = self._description(tool_config, tool_runtime) - - return DifyPluginToolConfig( - plugin_id=plugin_id, - provider=provider, - tool_name=exposed_name, - credential_type=self._credential_type(tool_config, runtime.credentials), - name=exposed_name, - description=description, - credentials=self._normalize_credentials(runtime.credentials, tool_name=exposed_name), - runtime_parameters=runtime_parameters, - parameters=parameters, - parameters_json_schema=self._plugin_parameters_json_schema(tool_runtime, parameters), - ) - - def _to_core_backend_tool_config( - self, - tool_config: AgentSoulDifyToolConfig, - tool_runtime: Tool, - exposed_name: str, - ) -> DifyCoreToolConfig: - parameters = self._prepared_parameters(tool_runtime) - return DifyCoreToolConfig( - provider_type=_CORE_TOOL_PROVIDER_TYPES[tool_config.provider_type], - provider_id=self._provider_id(tool_config), - tool_name=tool_config.tool_name or exposed_name, - credential_id=tool_config.credential_ref.id if tool_config.credential_ref else None, - name=exposed_name, - description=self._description(tool_config, tool_runtime), - runtime_parameters=self._runtime_parameters(tool_runtime, parameters), - parameters=parameters, - parameters_json_schema=tool_runtime.get_llm_parameters_json_schema(), - ) - - @staticmethod - def _credential_type( - tool_config: AgentSoulDifyToolConfig, - credentials: Mapping[str, Any], - ) -> DifyPluginToolCredentialType: - if not credentials and tool_config.credential_type == "unauthorized": - return "unauthorized" - return tool_config.credential_type - - @staticmethod - def _prepared_parameters(tool_runtime: Tool) -> list[DifyPluginToolParameter]: - return [ - DifyPluginToolParameter.model_validate(parameter.model_dump(mode="json")) - for parameter in tool_runtime.get_merged_runtime_parameters() - ] - - @staticmethod - def _description(tool_config: AgentSoulDifyToolConfig, tool_runtime: Tool) -> str | None: - description = tool_config.description - if description is None and tool_runtime.entity.description is not None: - description = tool_runtime.entity.description.llm - return description - - @staticmethod - def _plugin_parameters_json_schema( - tool_runtime: Tool, - parameters: list[DifyPluginToolParameter], - ) -> dict[str, Any]: - schema = tool_runtime.get_llm_parameters_json_schema() - properties = schema.setdefault("properties", {}) - required = schema.setdefault("required", []) - if not isinstance(properties, dict) or not isinstance(required, list): - raise WorkflowAgentDifyToolsBuildError( - "agent_tool_declaration_invalid", - f"Dify Plugin Tool {tool_runtime.entity.identity.name!r} has invalid parameter schema.", - ) - - for parameter in parameters: - if parameter.form is not DifyPluginToolParameterForm.LLM: - continue - if parameter.type is DifyPluginToolParameterType.FILE: - properties[parameter.name] = _plugin_file_input_schema(parameter.llm_description or "") - elif parameter.type in { - DifyPluginToolParameterType.FILES, - DifyPluginToolParameterType.SYSTEM_FILES, - }: - properties[parameter.name] = { - "type": "array", - "items": _plugin_file_input_schema(parameter.llm_description or ""), - "description": parameter.llm_description or "", - } - else: - continue - - if parameter.required and parameter.name not in required: - required.append(parameter.name) - return schema - - @staticmethod - def _runtime_parameters( - tool_runtime: Tool, - parameters: list[DifyPluginToolParameter], - ) -> dict[str, Any]: - runtime = tool_runtime.runtime - runtime_parameters = dict(runtime.runtime_parameters if runtime is not None else {}) - missing = [ - parameter.name - for parameter in parameters - if parameter.form is not DifyPluginToolParameterForm.LLM - and parameter.required - and parameter.default is None - and parameter.name not in runtime_parameters - ] - if missing: - names = ", ".join(sorted(missing)) - raise WorkflowAgentDifyToolsBuildError( - "agent_tool_runtime_parameter_missing", - f"Dify Tool {tool_runtime.entity.identity.name!r} is missing runtime parameters: {names}.", - ) - return runtime_parameters - - @staticmethod - def _normalize_credentials( - credentials: Mapping[str, Any], - *, - tool_name: str, - ) -> dict[str, DifyPluginCredentialValue]: - normalized: dict[str, DifyPluginCredentialValue] = {} - for key, value in credentials.items(): - if isinstance(value, str | int | float | bool) or value is None: - normalized[key] = value - continue - raise WorkflowAgentDifyToolsBuildError( - "agent_tool_credential_shape_invalid", - ( - f"Dify Plugin Tool {tool_name!r} credential {key!r} has a non-scalar value " - f"({type(value).__name__}); only str/int/float/bool/None are forwarded to the daemon." - ), - ) - return normalized - - -def _is_plugin_provider_id(provider_id: str | None) -> bool: - if not provider_id: - return False - parts = provider_id.split("/") - return len(parts) == 3 and all(parts) - - -def _plugin_file_input_schema(description: str) -> dict[str, Any]: - return { - "description": description, - "anyOf": [ - { - "type": "string", - "minLength": 1, - "description": "HTTP(S) URL or sandbox-local file path.", - }, - { - "type": "object", - "additionalProperties": False, - "required": ["transfer_method", "url"], - "properties": { - "transfer_method": {"type": "string", "enum": ["remote_url"]}, - "url": {"type": "string", "minLength": 1}, - }, - }, - { - "type": "object", - "additionalProperties": False, - "required": ["transfer_method", "reference"], - "properties": { - "transfer_method": { - "type": "string", - "enum": ["local_file", "tool_file", "datasource_file"], - }, - "reference": {"type": "string", "minLength": 1}, - }, - }, - ], - } diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py deleted file mode 100644 index 11403182af0de1..00000000000000 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ /dev/null @@ -1,1158 +0,0 @@ -from __future__ import annotations - -import json -from collections.abc import Mapping, Sequence -from dataclasses import dataclass -from typing import Any, Literal, Protocol, assert_never, cast - -from agenton.compositor import CompositorSessionSnapshot -from dify_agent.agent_stub.protocol import AgentStubFileMapping -from dify_agent.layers.ask_human import DifyAskHumanLayerConfig -from dify_agent.layers.config import ( - DifyConfigFileConfig, - DifyConfigLayerConfig, - DifyConfigSkillConfig, - DifyConfigVersionConfig, -) -from dify_agent.layers.execution_context import ( - DifyExecutionContextInvokeFrom, - DifyExecutionContextLayerConfig, - DifyExecutionContextUserFrom, -) -from dify_agent.layers.knowledge import ( - DifyKnowledgeBaseLayerConfig, - DifyKnowledgeDatasetConfig, - DifyKnowledgeMetadataFilteringConfig, - DifyKnowledgeModelConfig, - DifyKnowledgeQueryConfig, - DifyKnowledgeRerankingModelConfig, - DifyKnowledgeRetrievalConfig, - DifyKnowledgeSetConfig, -) -from dify_agent.layers.shell import ( - DifyShellCliToolConfig, - DifyShellEnvVarConfig, - DifyShellLayerConfig, - DifyShellSecretRefConfig, -) -from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload -from pydantic import BaseModel, ValidationError -from sqlalchemy.exc import OperationalError - -from clients.agent_backend import ( - AgentBackendModelConfig, - AgentBackendOutputConfig, - AgentBackendRunRequestBuilder, - AgentBackendWorkflowNodeRunInput, - redact_for_agent_backend_log, -) -from configs import dify_config -from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom -from core.app.llm.model_access import resolve_model_context_window -from core.plugin.provider_identity import normalize_plugin_daemon_provider_identity -from core.workflow.system_variables import SystemVariableKey, get_system_text, get_system_value -from graphon.file import File, FileTransferMethod -from graphon.variables.segments import Segment -from graphon.variables.template_resolution import convert_template -from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding -from models.agent_config_entities import ( - AgentKnowledgeMetadataFilteringConfig, - AgentKnowledgeModelConfig, - AgentKnowledgeRetrievalConfig, - AgentSoulConfig, - AgentSoulToolsConfig, - DeclaredArrayItem, - DeclaredOutputChildConfig, - DeclaredOutputConfig, - DeclaredOutputType, - WorkflowNodeJobConfig, - WorkflowOutputRoutes, - WorkflowPreviousNodeOutputRef, -) -from models.provider_ids import ModelProviderID -from services.agent.prompt_mentions import ( - MentionKind, - build_node_job_mention_resolver, - build_soul_mention_resolver, - expand_prompt_mentions, - extract_workflow_node_output_selectors, - normalize_previous_node_output_selector, - parse_prompt_mentions, - workflow_previous_node_output_refs_from_selectors, -) - -from .dify_tools_builder import ( - WorkflowAgentDifyToolLayersBuilder, - WorkflowAgentDifyToolsBuilder, - WorkflowAgentDifyToolsBuildError, - WorkflowAgentToolLayers, -) -from .output_failure_orchestrator import retry_idempotency_key -from .runtime_feature_manifest import build_runtime_feature_manifest - -_DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"}) -_DANGEROUS_FLAG_KEYS = ("dangerous", "dangerous_command", "requires_confirmation") -_DANGEROUS_ACK_KEYS = ( - "dangerous_acknowledged", - "dangerous_accepted", - "risk_accepted", - "approved", -) -type AgentStubFileTransferMethod = Literal["local_file", "tool_file", "datasource_file", "remote_url"] -_AGENT_STUB_FILE_TRANSFER_METHODS: Mapping[FileTransferMethod, AgentStubFileTransferMethod] = { - FileTransferMethod.LOCAL_FILE: "local_file", - FileTransferMethod.TOOL_FILE: "tool_file", - FileTransferMethod.DATASOURCE_FILE: "datasource_file", - FileTransferMethod.REMOTE_URL: "remote_url", -} -_CANONICAL_DIFY_FILE_REFERENCE_PATTERN = r"^dify-file-ref:eyJyZWNvcmRfaWQiOi[A-Za-z0-9_-]+={0,2}$" - - -class WorkflowAgentRuntimeRequestBuildError(ValueError): - """Raised when workflow state cannot be mapped to a valid Agent backend run request.""" - - def __init__(self, error_code: str, message: str) -> None: - self.error_code = error_code - super().__init__(message) - - -class VariablePoolReader(Protocol): - def get(self, selector: Sequence[str], /) -> Segment | None: ... - - def get_by_prefix(self, prefix: str, /) -> Mapping[str, object]: ... - - -@dataclass(frozen=True, slots=True) -class WorkflowAgentRuntimeBuildContext: - dify_context: DifyRunContext - workflow_id: str - workflow_run_id: str | None - node_id: str - node_execution_id: str - variable_pool: VariablePoolReader - binding: WorkflowAgentNodeBinding - agent: Agent - snapshot: AgentConfigSnapshot - binding_id: str - backend_binding_ref: str - # Stage 4 §7 / D-4: 0 for the first run, then incremented per retry. Drives the - # idempotency key so the backend treats each retry as a fresh request. - attempt: int = 0 - session_snapshot: CompositorSessionSnapshot | None = None - # ENG-638: set when resuming after a submitted ask_human HITL form; threads - # the human's answer back into the second Agent run keyed by tool_call_id. - deferred_tool_results: DeferredToolResultsPayload | None = None - - -@dataclass(frozen=True, slots=True) -class WorkflowAgentRuntimeRequest: - request: CreateRunRequest - redacted_request: dict[str, Any] - agent_soul: AgentSoulConfig - node_job: WorkflowNodeJobConfig - metadata: dict[str, Any] - - -class WorkflowAgentRuntimeRequestBuilder: - """Build public Dify Agent run requests from workflow Agent v2 runtime state.""" - - _WORKFLOW_USER_PROMPT_FALLBACK = "Use the current workflow context." - _WORKFLOW_JOB_PROMPT_FALLBACK = "Use the workflow user prompt for this run." - - def __init__( - self, - *, - request_builder: AgentBackendRunRequestBuilder | None = None, - dify_tools_builder: WorkflowAgentDifyToolLayersBuilder | None = None, - ) -> None: - self._request_builder = request_builder or AgentBackendRunRequestBuilder() - self._dify_tools_builder = dify_tools_builder or WorkflowAgentDifyToolsBuilder() - - def build(self, context: WorkflowAgentRuntimeBuildContext) -> WorkflowAgentRuntimeRequest: - agent_soul = AgentSoulConfig.model_validate(context.snapshot.config_snapshot_dict) - node_job = WorkflowNodeJobConfig.model_validate(context.binding.node_job_config_dict) - node_job.output_routes.validate_for_execution() - if agent_soul.model is None: - raise WorkflowAgentRuntimeRequestBuildError( - "agent_model_not_configured", - "Workflow Agent node requires Agent Soul model config.", - ) - - metadata = self._build_metadata(context, agent_soul, node_job) - effective_node_job = node_job.model_copy( - update={"previous_node_output_refs": self._effective_previous_node_output_refs(node_job)} - ) - workflow_task_prompt = self._build_workflow_task_prompt(context, effective_node_job) - workflow_context_prompt = self._build_workflow_context_prompt(context, effective_node_job) - workflow_job_prompt = workflow_task_prompt or self._WORKFLOW_JOB_PROMPT_FALLBACK - user_prompt = workflow_context_prompt or self._WORKFLOW_USER_PROMPT_FALLBACK - try: - tool_layers = self._build_tool_layers( - tenant_id=context.dify_context.tenant_id, - app_id=context.dify_context.app_id, - user_id=context.dify_context.user_id, - tools=agent_soul.tools, - invoke_from=context.dify_context.invoke_from, - ) - except WorkflowAgentDifyToolsBuildError as error: - raise WorkflowAgentRuntimeRequestBuildError(error.error_code, str(error)) from error - if tool_layers.plugin_tools is not None or tool_layers.core_tools is not None or agent_soul.tools.cli_tools: - metadata["agent_tools"] = { - "dify_tool_count": len(tool_layers.exposed_tool_names()), - "dify_tool_names": tool_layers.exposed_tool_names(), - "cli_tool_count": len(agent_soul.tools.cli_tools), - } - - runtime_config_skills = load_runtime_agent_skill_configs( - tenant_id=context.dify_context.tenant_id, - agent_id=context.agent.id, - ) - config_layer_config, config_warnings = build_config_layer_config( - agent_soul, - agent_id=context.agent.id, - config_version_id=context.snapshot.id, - config_version_kind="snapshot", - runtime_config_skills=runtime_config_skills, - ) - append_runtime_warnings(metadata, config_warnings) - soul_prompt_resolver = build_config_aware_soul_mention_resolver( - agent_soul, - runtime_config_skills=runtime_config_skills, - ) - soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip() - knowledge_config = build_knowledge_layer_config(agent_soul) - context_window_tokens = resolve_model_context_window( - run_context=context.dify_context, - provider_name=agent_soul.model.model_provider, - model_name=agent_soul.model.model, - ) - model_plugin_id, model_provider = normalize_plugin_daemon_provider_identity( - ModelProviderID(agent_soul.model.model_provider), - agent_soul.model.plugin_id, - ) - - request = self._request_builder.build_for_workflow_node( - AgentBackendWorkflowNodeRunInput( - model=AgentBackendModelConfig( - plugin_id=model_plugin_id, - model_provider=model_provider, - model=agent_soul.model.model, - model_settings=agent_soul.model.model_settings.model_dump(mode="json", exclude_none=True), - context_window_tokens=context_window_tokens, - ), - # The execution-context layer is now the only public protocol - # carrier for Dify tenant/user/run identifiers. ``user_id`` and - # ``user_from`` must be forwarded here because downstream plugin- - # API model gateway, daemon tool clients, and knowledge-base - # layers read caller identity from this layer rather than from - # any parallel top-level request field. - execution_context=DifyExecutionContextLayerConfig( - tenant_id=context.dify_context.tenant_id, - user_id=context.dify_context.user_id, - user_from=cast(DifyExecutionContextUserFrom, context.dify_context.user_from.value), - app_id=context.dify_context.app_id, - workflow_id=context.workflow_id, - workflow_run_id=context.workflow_run_id, - node_id=context.node_id, - node_execution_id=context.node_execution_id, - conversation_id=get_system_text(context.variable_pool, SystemVariableKey.CONVERSATION_ID), - agent_id=context.agent.id, - agent_config_version_id=context.snapshot.id, - agent_config_version_kind="snapshot", - agent_mode=self._agent_backend_agent_mode(context.dify_context.invoke_from), - invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value), - ), - backend_binding_ref=context.backend_binding_ref, - agent_soul_prompt=soul_prompt or None, - workflow_node_job_prompt=workflow_job_prompt, - user_prompt=user_prompt, - output=self._build_output_config( - node_job.declared_outputs, node_job.output_routes, context.variable_pool - ), - tools=tool_layers.plugin_tools, - core_tools=tool_layers.core_tools, - knowledge=knowledge_config, - config_layer_config=config_layer_config, - ask_human_config=build_ask_human_layer_config(agent_soul), - include_shell=dify_config.AGENT_SHELL_ENABLED, - shell_config=build_shell_layer_config(agent_soul), - session_snapshot=context.session_snapshot, - deferred_tool_results=context.deferred_tool_results, - idempotency_key=self._idempotency_key(context), - metadata=metadata, - ) - ) - redacted = cast(dict[str, Any], redact_for_agent_backend_log(request)) - return WorkflowAgentRuntimeRequest( - request=request, - redacted_request=redacted, - agent_soul=agent_soul, - node_job=node_job, - metadata=metadata, - ) - - def _build_tool_layers( - self, - *, - tenant_id: str, - app_id: str, - user_id: str | None, - tools: AgentSoulToolsConfig, - invoke_from: InvokeFrom, - ) -> WorkflowAgentToolLayers: - # Production workflow runs intentionally keep existing plugin configs on - # the direct `dify.plugin.tools` route. This builder emits plugin tools - # directly and non-plugin Dify tools through `dify.core.tools`. - return self._dify_tools_builder.build_layers( - tenant_id=tenant_id, - app_id=app_id, - user_id=user_id, - tools=tools, - invoke_from=invoke_from, - ) - - @staticmethod - def _agent_backend_agent_mode(invoke_from: InvokeFrom) -> Literal["workflow_run", "single_step"]: - if invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.VALIDATION}: - return "single_step" - return "workflow_run" - - @staticmethod - def _idempotency_key(context: WorkflowAgentRuntimeBuildContext) -> str: - # Stage 4 §7 / D-4: retries get distinct keys (``...:retry-{attempt}``) so - # the Agent backend's protocol-level dedup can't replay a previous run. - return retry_idempotency_key( - workflow_run_id=context.workflow_run_id, - node_execution_id=context.node_execution_id, - attempt=context.attempt, - ) - - @staticmethod - def _build_metadata( - context: WorkflowAgentRuntimeBuildContext, - agent_soul: AgentSoulConfig, - node_job: WorkflowNodeJobConfig, - ) -> dict[str, Any]: - return { - "tenant_id": context.dify_context.tenant_id, - "app_id": context.dify_context.app_id, - "workflow_id": context.workflow_id, - "workflow_run_id": context.workflow_run_id, - "node_id": context.node_id, - "node_execution_id": context.node_execution_id, - "agent_id": context.agent.id, - "agent_config_snapshot_id": context.snapshot.id, - "binding_id": context.binding.id, - "workflow_node_job_mode": node_job.mode.value, - "runtime_support": build_runtime_feature_manifest(agent_soul), - } - - def _build_workflow_context_prompt( - self, - context: WorkflowAgentRuntimeBuildContext, - node_job: WorkflowNodeJobConfig, - ) -> str: - lines: list[str] = [] - query = get_system_text(context.variable_pool, SystemVariableKey.QUERY) - uploaded_files = self._summarize_uploaded_workflow_files(context.variable_pool) - resolved_outputs = self._resolve_previous_node_outputs( - context.variable_pool, - node_job.previous_node_output_refs, - ) - if not query and uploaded_files is None and not resolved_outputs: - return "" - - lines.append("Workflow context loaded for this run:") - if query: - lines.append(f"- User query: {query}") - - if uploaded_files is not None: - lines.append("- Uploaded workflow files:") - lines.append(f" - sys.files: {uploaded_files}") - - if resolved_outputs: - lines.append("- Previous node outputs:") - for item in resolved_outputs: - lines.append(f" - {item['label']}: {item['value']}") - - lines.append("The above workflow context is run-specific. Do not treat it as Agent Soul or persistent memory.") - return "\n".join(lines) - - def _summarize_uploaded_workflow_files(self, variable_pool: VariablePoolReader) -> str | None: - files = get_system_value(variable_pool, SystemVariableKey.FILES) - if files is None: - return None - if isinstance(files, list | tuple) and not files: - return None - return self._summarize_value(files) - - def _build_workflow_task_prompt( - self, - context: WorkflowAgentRuntimeBuildContext, - node_job: WorkflowNodeJobConfig, - ) -> str: - del context - return expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip() - - def _effective_previous_node_output_refs( - self, - node_job: WorkflowNodeJobConfig, - ) -> list[WorkflowPreviousNodeOutputRef]: - """Derive effective refs from the current frontend task markers. - - The task text is the source of truth for previous-node context. When - the prompt has no frontend markers, stale persisted refs are discarded - instead of being carried forward into workflow context. - """ - if not node_job.workflow_prompt: - return list(node_job.previous_node_output_refs) - - return workflow_previous_node_output_refs_from_selectors( - extract_workflow_node_output_selectors(node_job.workflow_prompt) - ) - - def _resolve_previous_node_outputs( - self, - variable_pool: VariablePoolReader, - refs: Sequence[WorkflowPreviousNodeOutputRef], - ) -> list[dict[str, Any]]: - resolved: list[dict[str, Any]] = [] - for ref in refs: - selector = normalize_previous_node_output_selector(ref) - if not selector: - raise WorkflowAgentRuntimeRequestBuildError( - "invalid_previous_node_output_ref", - "Workflow Agent node has invalid previous node output ref.", - ) - segment = variable_pool.get(selector) - if segment is None: - raise WorkflowAgentRuntimeRequestBuildError( - "missing_previous_node_output", - f"Workflow Agent node cannot resolve previous node output {'.'.join(selector)}.", - ) - value = getattr(segment, "value", None) - resolved.append( - { - "label": ".".join(selector), - "value": self._summarize_value(value), - } - ) - return resolved - - @staticmethod - def _summarize_value(value: Any) -> str: - prompt_payload, used_download_mapping = WorkflowAgentRuntimeRequestBuilder._resolve_prompt_payload_value(value) - if used_download_mapping: - return json.dumps(prompt_payload, ensure_ascii=False, separators=(",", ":")) - - text = str(value) - if len(text) > 2000: - return text[:2000] + "...[truncated]" - return text - - @classmethod - def _resolve_prompt_payload_value(cls, value: Any) -> tuple[Any, bool]: - # File-valued workflow context must surface as Agent Stub download - # mappings so the model can materialize those inputs with - # `dify-agent file download TRANSFER_METHOD REFERENCE_OR_URL` inside the sandbox. - download_mapping = cls._agent_stub_download_mapping(value) - if download_mapping is not None: - return download_mapping, True - - if isinstance(value, list | tuple): - changed = False - resolved_items: list[Any] = [] - for item in value: - resolved_item, item_changed = cls._resolve_prompt_payload_value(item) - resolved_items.append(resolved_item) - changed = changed or item_changed - return resolved_items, changed - - if isinstance(value, Mapping): - changed = False - resolved_items_by_key: dict[str, Any] = {} - for key, item in value.items(): - resolved_item, item_changed = cls._resolve_prompt_payload_value(item) - resolved_items_by_key[str(key)] = resolved_item - changed = changed or item_changed - return resolved_items_by_key, changed - - return value, False - - @staticmethod - def _agent_stub_download_mapping(value: Any) -> dict[str, Any] | None: - try: - if isinstance(value, File): - transfer_method = _AGENT_STUB_FILE_TRANSFER_METHODS.get(value.transfer_method) - if transfer_method is None: - return None - if value.transfer_method == FileTransferMethod.REMOTE_URL: - url = value.remote_url - if not isinstance(url, str) or not url: - return None - mapping = AgentStubFileMapping(transfer_method=transfer_method, url=url) - else: - reference = value.reference - if not isinstance(reference, str) or not reference: - return None - mapping = AgentStubFileMapping(transfer_method=transfer_method, reference=reference) - return mapping.model_dump(mode="json", exclude_none=True) - - if isinstance(value, Mapping): - transfer_method = value.get("transfer_method") - if not isinstance(transfer_method, str): - return None - if transfer_method == "remote_url": - mapping = AgentStubFileMapping( - transfer_method="remote_url", - url=value.get("url") or value.get("remote_url"), - ) - elif transfer_method in {"local_file", "tool_file", "datasource_file"}: - mapping = AgentStubFileMapping( - transfer_method=cast(AgentStubFileTransferMethod, transfer_method), - reference=value.get("reference"), - ) - else: - return None - return mapping.model_dump(mode="json", exclude_none=True) - except ValidationError: - return None - - return None - - @staticmethod - def _build_output_config( - declared_outputs: Sequence[DeclaredOutputConfig], - output_routes: WorkflowOutputRoutes | None = None, - variable_pool: VariablePoolReader | None = None, - ) -> AgentBackendOutputConfig | None: - """Build the structured-output layer config sent to Agent backend. - - Enabled routing adds a required system ``switch`` - in the same Agent call. Omit the structured-output layer only when there - are no custom outputs and routing is disabled. - """ - if not declared_outputs and (output_routes is None or not output_routes.enabled): - return None - - properties: dict[str, Any] = {"text": {"type": "string"}} - required: list[str] = [] - if output_routes is not None and output_routes.enabled: - if variable_pool is None: - raise ValueError("Output route selection requires a workflow variable pool.") - descriptions = [ - f"{route.id} ({route.label or route.id}): {convert_template(variable_pool, route.name).text}" - for route in output_routes.routes - ] - properties["switch"] = { - "type": "string", - "enum": [route.id for route in output_routes.routes], - "description": "Select exactly one route ID based on the task result:\n" + "\n".join(descriptions), - } - required.append("switch") - for output in declared_outputs: - properties[output.name] = WorkflowAgentRuntimeRequestBuilder._schema_for_declared_output(output) - if output.required: - required.append(output.name) - schema: dict[str, Any] = {"type": "object", "properties": properties} - if required: - schema["required"] = required - return AgentBackendOutputConfig( - json_schema=schema, - description=WorkflowAgentRuntimeRequestBuilder._build_output_description(declared_outputs), - ) - - @staticmethod - def _schema_for_declared_output(output: DeclaredOutputConfig) -> dict[str, Any]: - schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type( - output.type, - array_item=output.array_item, - children=output.children, - ) - if output.description: - schema["description"] = output.description - return schema - - @staticmethod - def _schema_for_type( - output_type: DeclaredOutputType, - *, - array_item: DeclaredArrayItem | None = None, - children: Sequence[DeclaredOutputChildConfig] | None = None, - ) -> dict[str, Any]: - match output_type: - case DeclaredOutputType.STRING: - return {"type": "string"} - case DeclaredOutputType.NUMBER: - return {"type": "number"} - case DeclaredOutputType.BOOLEAN: - return {"type": "boolean"} - case DeclaredOutputType.OBJECT: - object_schema: dict[str, Any] = {"type": "object"} - WorkflowAgentRuntimeRequestBuilder._apply_child_properties(object_schema, children or []) - return object_schema - case DeclaredOutputType.ARRAY: - # Stage 4 §4.2: items shape mirrors the declared array_item. - # Validator guarantees array_item is set when type is array. - item_type = array_item.type if array_item else DeclaredOutputType.OBJECT - array_schema: dict[str, Any] = { - "type": "array", - "items": WorkflowAgentRuntimeRequestBuilder._schema_for_type( - item_type, - children=array_item.children if array_item else None, - ), - } - if array_item is not None and array_item.description: - array_schema["items"]["description"] = array_item.description - return array_schema - case DeclaredOutputType.FILE: - return WorkflowAgentRuntimeRequestBuilder._agent_stub_output_file_mapping_schema() - assert_never(output_type) - - @staticmethod - def _agent_stub_output_file_mapping_schema() -> dict[str, Any]: - """JSON Schema for Agent-produced output file mappings. - - ``AgentStubFileMapping.model_json_schema()`` cannot express the model's - ``after`` validator: only ``remote_url`` may carry ``url``; every other - method must carry a canonical ``reference``. The structured-output model - needs that relationship in the schema, otherwise it may emit - ``{"transfer_method": "local_file", "url": "..."}``, which passes the - broad generated schema but fails API-side output type checking. - - For files produced inside an Agent run, the supported persisted shape is - narrower than the full CLI upload stdout: the sandbox must upload the - local artifact via ``dify-agent file upload ``, then use the - returned ``reference`` inside the accepted ``tool_file`` mapping shape - for structured ``final_output``. ``local_file`` and ``datasource_file`` - are valid for existing file references in workflow context, not for - newly produced Agent output files. - """ - return { - "title": "AgentStubFileMapping", - "description": ( - "Agent output file mapping. Use `tool_file` with `reference` for files uploaded by " - "`dify-agent file upload `; use `remote_url` only for files already reachable by URL." - ), - "anyOf": [ - { - "type": "object", - "additionalProperties": False, - "required": ["transfer_method", "reference"], - "properties": { - "transfer_method": { - "type": "string", - "enum": ["tool_file"], - }, - "reference": { - "type": "string", - "minLength": 1, - "pattern": _CANONICAL_DIFY_FILE_REFERENCE_PATTERN, - "description": ( - "Canonical Dify file reference returned by `dify-agent file upload `. " - "Never use a local path, filename, URL, or synthesized dify-file-ref here." - ), - }, - }, - }, - { - "type": "object", - "additionalProperties": False, - "required": ["transfer_method", "url"], - "properties": { - "transfer_method": { - "type": "string", - "enum": ["remote_url"], - }, - "url": { - "type": "string", - "minLength": 1, - "description": "Remote URL for a file that is already publicly reachable.", - }, - }, - }, - ], - } - - @staticmethod - def _build_output_description(declared_outputs: Sequence[DeclaredOutputConfig]) -> str | None: - file_output_lines: list[str] = [] - for output in declared_outputs: - if output.type == DeclaredOutputType.FILE: - file_output_lines.append( - f"- `{output.name}`: create the file in the sandbox, run `dify-agent file upload `, " - f"then set `final_output.{output.name}` to a `tool_file` mapping using the returned " - f"`reference`. Do not call `final_output` before the upload command succeeds. Do not use " - "the local path, filename, URL, or a synthesized/base64-encoded value as the `reference`." - ) - elif ( - output.type == DeclaredOutputType.ARRAY - and output.array_item is not None - and output.array_item.type == DeclaredOutputType.FILE - ): - file_output_lines.append( - f"- `{output.name}`: for every produced file, run `dify-agent file upload ` and set " - f"`final_output.{output.name}` to an array of `tool_file` mappings using the returned " - f"`reference` values. Do not call `final_output` before all upload commands succeed. Do not use " - "local paths, filenames, URLs, or synthesized/base64-encoded values as `reference` values." - ) - if not file_output_lines: - return None - - return "\n".join( - [ - "When filling file outputs, do not return a local filesystem path directly.", - "Upload each sandbox-local file through the Agent Stub CLI first. For structured `final_output`, use " - "only the accepted file-mapping shape and the returned `reference`; never invent the `reference` " - "value.", - "If you are replying to the user in natural language and want them to open or download the produced " - "file, include the returned `public_download_url` in that reply instead of copying it into structured " - "`final_output` unless the schema explicitly asks for it.", - *file_output_lines, - ] - ) - - @staticmethod - def _apply_child_properties(schema: dict[str, Any], children: Sequence[DeclaredOutputChildConfig]) -> None: - if not children: - return - properties: dict[str, Any] = {} - required: list[str] = [] - for child in children: - child_schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type( - child.type, - array_item=child.array_item, - children=child.children, - ) - if child.description: - child_schema["description"] = child.description - properties[child.name] = child_schema - if child.required: - required.append(child.name) - schema["properties"] = properties - if required: - schema["required"] = required - - -def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfig: - """Map Agent Soul shell-adjacent fields into the Agent backend shell config.""" - return DifyShellLayerConfig( - cli_tools=[ - tool - for tool in (_shell_cli_tool(item) for item in agent_soul.tools.cli_tools if _cli_tool_enabled(item)) - if tool is not None - ], - env=_shell_env_vars(agent_soul.env.variables, agent_soul.env.secret_refs), - secret_refs=[ - secret for secret in (_shell_secret_ref(item) for item in agent_soul.env.secret_refs) if secret is not None - ], - ) - - -def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBaseLayerConfig | None: - """Map Agent Soul knowledge sets into one Dify knowledge-base layer. - - Agent Soul DTO validation owns malformed set rejection. Runtime mapping is - intentionally lossless: every configured set is forwarded with its query - policy, dataset refs, retrieval controls, and metadata-filtering controls. - ``score_threshold=None`` means disabled threshold filtering and maps to the - inner retrieval request's ``0.0`` default through the Agent backend DTO. - """ - if not agent_soul.knowledge.sets: - return None - - return DifyKnowledgeBaseLayerConfig( - sets=[ - DifyKnowledgeSetConfig( - id=knowledge_set.id, - name=knowledge_set.name, - description=knowledge_set.description, - datasets=[ - DifyKnowledgeDatasetConfig( - id=dataset.id or "", - name=dataset.name, - description=dataset.description, - ) - for dataset in knowledge_set.datasets - ], - query=DifyKnowledgeQueryConfig( - mode=cast(Literal["user_query", "generated_query"], knowledge_set.query.mode.value), - value=knowledge_set.query.value, - ), - retrieval=_knowledge_retrieval_config(knowledge_set.retrieval), - metadata_filtering=_knowledge_metadata_filtering_config(knowledge_set.metadata_filtering), - ) - for knowledge_set in agent_soul.knowledge.sets - ], - ) - - -def _knowledge_retrieval_config(retrieval: AgentKnowledgeRetrievalConfig) -> DifyKnowledgeRetrievalConfig: - weights = None - if retrieval.weights is not None: - # The dify-agent runtime payload only consumes the nested vector/keyword - # settings; ``weight_type`` is an API-side authoring detail and must not - # leak into the inner request shape. - weights = ( - cast( - dict[str, Any], - { - key: value - for key, value in { - "vector_setting": retrieval.weights.vector_setting, - "keyword_setting": retrieval.weights.keyword_setting, - }.items() - if value is not None - }, - ) - or None - ) - - return DifyKnowledgeRetrievalConfig( - mode=retrieval.mode, - top_k=retrieval.top_k, - score_threshold=retrieval.score_threshold or 0.0, - reranking_mode=retrieval.reranking_mode, - reranking_enable=retrieval.reranking_enable, - reranking_model=DifyKnowledgeRerankingModelConfig( - provider=retrieval.reranking_model.provider, - model=retrieval.reranking_model.model, - ) - if retrieval.reranking_model is not None - else None, - weights=weights, - model=_knowledge_model_config(retrieval.model), - ) - - -def _knowledge_metadata_filtering_config( - metadata_filtering: AgentKnowledgeMetadataFilteringConfig, -) -> DifyKnowledgeMetadataFilteringConfig: - return DifyKnowledgeMetadataFilteringConfig( - mode=metadata_filtering.mode, - model_config=_knowledge_model_config(metadata_filtering.metadata_model_config), - conditions=cast( - Any, - metadata_filtering.conditions.model_dump( - mode="json", exclude={"conditions": {"__all__": {"id", "metadata_id"}}} - ), - ) - if metadata_filtering.conditions is not None - else None, - ) - - -def _knowledge_model_config(model: AgentKnowledgeModelConfig | None) -> DifyKnowledgeModelConfig | None: - if model is None: - return None - return DifyKnowledgeModelConfig( - provider=model.provider, - name=model.name, - mode=model.mode, - completion_params=model.completion_params, - ) - - -def build_ask_human_layer_config(agent_soul: AgentSoulConfig) -> DifyAskHumanLayerConfig | None: - """Enable the dify.ask_human deferred tool when the soul configures human involvement. - - HITL is opt-in: only when at least one human contact is configured does the - model get the ``ask_human`` tool (recipients for the resulting form come from - those contacts, ENG-635). Returns ``None`` to leave the tool off entirely. - The tool/field guardrails use the layer defaults; ``human.tools`` semantics are - out of scope this round. - """ - if not agent_soul.human.contacts: - return None - return DifyAskHumanLayerConfig() - - -def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, str]]) -> None: - """Merge build-time warnings into the metadata runtime-support manifest.""" - if not warnings: - return - manifest = metadata.setdefault("runtime_support", {}) - if isinstance(manifest, dict): - existing = manifest.setdefault("unsupported_runtime_warnings", []) - if isinstance(existing, list): - existing.extend(warnings) - - -def build_config_aware_soul_mention_resolver( - agent_soul: AgentSoulConfig, - *, - runtime_config_skills: Sequence[DifyConfigSkillConfig] = (), -): - """Resolve config skill/file mentions and delegate the rest to Agent Soul.""" - - base_resolver = build_soul_mention_resolver(agent_soul) - skill_names = {item.name for item in agent_soul.config_skills if not item.is_missing} - skill_names.update(item.name for item in runtime_config_skills) - file_names = {item.name for item in agent_soul.config_files if not item.is_missing} - - def _resolve(mention: object) -> str | None: - if not hasattr(mention, "kind") or not hasattr(mention, "ref_id"): - return None - kind = cast(MentionKind, mention.kind) - ref_id = cast(str, mention.ref_id) - label = cast(str | None, getattr(mention, "label", None)) - if kind == MentionKind.SKILL: - return ref_id if ref_id in skill_names else label or ref_id - if kind == MentionKind.FILE: - return ref_id if ref_id in file_names else label or ref_id - return base_resolver(cast(Any, mention)) - - return _resolve - - -def load_runtime_agent_skill_configs(*, tenant_id: str, agent_id: str) -> list[DifyConfigSkillConfig]: - """Return workspace-bound Skills as prompt-safe runtime config skills.""" - from services.skill_management_service import SkillManagementService - - try: - runtime_skills = SkillManagementService().list_runtime_agent_skills(tenant_id=tenant_id, agent_id=agent_id) - except OperationalError as exc: - if "no such table: agent_skill_bindings" not in str(exc.orig): - raise - runtime_skills = [] - return [ - DifyConfigSkillConfig( - name=str(item["name"]), - description=str(item.get("description") or ""), - size=cast(int | None, item.get("size")), - mime_type=cast(str | None, item.get("mime_type")), - ) - for item in runtime_skills - ] - - -def build_config_layer_config( - agent_soul: AgentSoulConfig, - *, - agent_id: str | None = None, - config_version_id: str | None = None, - config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot", - runtime_config_skills: Sequence[DifyConfigSkillConfig] = (), -) -> tuple[DifyConfigLayerConfig, list[dict[str, str]]]: - """Build the always-present Agent config layer from Agent Soul state. - - The ``dify.config`` layer must exist for every Agent v2 runtime request so - the backend can expose the config CLI/help surface even when the current - Agent Soul has no config assets, note, or prompt mentions. - """ - - ordered_mentions = list( - dict.fromkeys( - mention.ref_id - for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt) - if mention.kind in {MentionKind.SKILL, MentionKind.FILE} and mention.ref_id - ) - ) - available_skills = [skill for skill in agent_soul.config_skills if not skill.is_missing] - skill_configs = [ - DifyConfigSkillConfig( - name=skill.name, - description=skill.description, - size=skill.size, - mime_type=skill.mime_type, - ) - for skill in available_skills - ] - seen_skill_names = {skill.name for skill in skill_configs} - for skill in runtime_config_skills: - if skill.name in seen_skill_names: - continue - seen_skill_names.add(skill.name) - skill_configs.append(skill) - available_files = [file_ref for file_ref in agent_soul.config_files if not file_ref.is_missing] - skill_names = {skill.name for skill in skill_configs} - file_names = {file_ref.name for file_ref in available_files} - warnings: list[dict[str, str]] = [ - { - "section": "agent_soul.config", - "code": "config_asset_missing", - "message": f"config {kind} '{item.name}' is unavailable and was excluded from runtime.", - } - for kind, items in (("skill", agent_soul.config_skills), ("file", agent_soul.config_files)) - for item in items - if item.is_missing - ] - mentioned_skill_names: list[str] = [] - mentioned_file_names: list[str] = [] - for name in ordered_mentions: - if name in skill_names: - mentioned_skill_names.append(name) - continue - if name in file_names: - mentioned_file_names.append(name) - continue - warnings.append( - { - "section": "agent_soul.prompt.system_prompt", - "code": "mention_target_missing", - "message": f"config mention '{name}' has no matching config asset.", - } - ) - - return ( - DifyConfigLayerConfig( - agent_id=agent_id, - config_version=DifyConfigVersionConfig( - id=config_version_id, - kind=config_version_kind, - writable=config_version_kind == "build_draft", - ), - skills=skill_configs, - files=[ - DifyConfigFileConfig( - name=file_ref.name, - size=file_ref.size, - mime_type=file_ref.mime_type, - ) - for file_ref in available_files - ], - env_keys=_agent_soul_config_env_keys(agent_soul), - note=agent_soul.config_note, - mentioned_skill_names=mentioned_skill_names, - mentioned_file_names=mentioned_file_names, - ), - warnings, - ) - - -def _agent_soul_config_env_keys(agent_soul: AgentSoulConfig) -> list[str]: - keys = [item.key or item.name or item.env_name or item.variable for item in agent_soul.env.variables] - return [key for key in keys if key] - - -def _cli_tool_enabled(item: object) -> bool: - """A CLI tool is bootstrapped unless explicitly disabled (default is enabled).""" - data = _plain_mapping(item) - if data.get("enabled") is False: - return False - if data.get("pre_authorized") is False or _permission_denied(data): - return False - if _dangerous_without_acknowledgement(data): - return False - return True - - -def _shell_cli_tool(item: object) -> DifyShellCliToolConfig | None: - data = _plain_mapping(item) - commands: list[str] = [] - raw_commands = data.get("install_commands") - if isinstance(raw_commands, list): - commands.extend(str(command) for command in raw_commands if str(command).strip()) - # ``command`` is the typed AgentCliToolConfig field; the rest are accepted aliases. - for key in ("install_command", "install", "setup_command", "command"): - raw_command = data.get(key) - if isinstance(raw_command, str) and raw_command.strip(): - commands.append(raw_command) - name = data.get("name") or data.get("tool_name") or data.get("label") - if not commands and not isinstance(name, str): - return None - tool_env = data.get("env") if isinstance(data.get("env"), Mapping) else {} - env = _shell_env_vars(_env_entries(tool_env, "variables"), _env_entries(tool_env, "secret_refs")) - secret_refs = [ - secret_ref - for secret_ref in (_shell_secret_ref(item) for item in _env_entries(tool_env, "secret_refs")) - if secret_ref is not None - ] - return DifyShellCliToolConfig( - name=name if isinstance(name, str) else None, - install_commands=commands, - env=env, - secret_refs=secret_refs, - ) - - -def _env_entries(env: object, key: str) -> list[object]: - if not isinstance(env, Mapping): - return [] - entries = env.get(key) - if not isinstance(entries, list): - return [] - return entries - - -def _shell_env_vars(variables: Sequence[object], secret_refs: Sequence[object]) -> list[DifyShellEnvVarConfig]: - env_vars = [_shell_env_var(item) for item in variables] - secret_env_vars = [_shell_env_var(item) for item in secret_refs if _has_secret_value(item)] - return [env for env in [*env_vars, *secret_env_vars] if env is not None] - - -def _shell_env_var(item: object) -> DifyShellEnvVarConfig | None: - data = _plain_mapping(item) - name = _name_from_mapping(data) - if name is None: - return None - value = data.get("value", data.get("default", "")) - if not isinstance(value, str): - value = str(value) - return DifyShellEnvVarConfig(name=name, value=value) - - -def _shell_secret_ref(item: object) -> DifyShellSecretRefConfig | None: - data = _plain_mapping(item) - name = _name_from_mapping(data) - if name is None: - return None - # Inline Composer values are passed as env vars because the agent-backend - # secret ref schema only accepts short backend-managed reference IDs. - if _has_secret_value(item): - return None - ref = data.get("ref") or data.get("credential_id") or data.get("provider_credential_id") - if ref is None: - ref = data.get("id") - if ref is None: - return None - return DifyShellSecretRefConfig(name=name, ref=str(ref) if ref is not None else None) - - -def _plain_mapping(item: object) -> dict[str, Any]: - if isinstance(item, BaseModel): - return item.model_dump(mode="python", exclude_none=True, exclude_defaults=True) - if isinstance(item, Mapping): - return dict(item) - return {} - - -def _has_secret_value(item: object) -> bool: - data = _plain_mapping(item) - value = data.get("value") - return isinstance(value, str) and bool(value) - - -def _name_from_mapping(item: Mapping[str, Any]) -> str | None: - for key in ("name", "key", "env_name", "variable"): - value = item.get(key) - if isinstance(value, str) and value.strip(): - return value.strip() - return None - - -def _permission_denied(data: Mapping[str, Any]) -> bool: - permission = data.get("permission") - if isinstance(permission, Mapping): - allowed = permission.get("allowed") - if allowed is False: - return True - status = permission.get("status") or permission.get("state") - if isinstance(status, str) and status in _DENIED_PERMISSION_STATUSES: - return True - - for key in ("authorization_status", "permission_status", "status"): - status = data.get(key) - if isinstance(status, str) and status in _DENIED_PERMISSION_STATUSES: - return True - return False - - -def _dangerous_without_acknowledgement(data: Mapping[str, Any]) -> bool: - dangerous = any(data.get(key) is True for key in _DANGEROUS_FLAG_KEYS) - risk_level = data.get("risk_level") - if isinstance(risk_level, str) and risk_level == "dangerous": - dangerous = True - if not dangerous: - return False - return not any(data.get(key) is True for key in _DANGEROUS_ACK_KEYS) diff --git a/api/core/workflow/nodes/agent_v2/validators.py b/api/core/workflow/nodes/agent_v2/validators.py deleted file mode 100644 index c92a3be9bb362a..00000000000000 --- a/api/core/workflow/nodes/agent_v2/validators.py +++ /dev/null @@ -1,660 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator, Mapping -from typing import Any - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from core.agent.publish_visibility import workflow_callable_active_snapshot_filter -from core.agent.workflow_references import ( - extract_workflow_node_output_selectors, - workflow_previous_node_output_refs_from_selectors, -) -from core.workflow.graph_topology import WorkflowGraphTopology -from graphon.enums import BuiltinNodeTypes, ErrorStrategy -from models.agent import ( - Agent, - AgentConfigSnapshot, - AgentScope, - AgentStatus, - WorkflowAgentBindingType, - WorkflowAgentNodeBinding, -) -from models.agent_config_entities import ( - AgentFileRefConfig, - AgentHumanContactConfig, - AgentSoulConfig, - WorkflowNodeJobConfig, - WorkflowPreviousNodeOutputRef, -) -from models.model import UploadFile -from models.workflow import Workflow -from services.agent.knowledge_datasets import list_missing_tenant_knowledge_dataset_ids - -from .discriminator import is_dify_agent_node_data -from .entities import DifyAgentNodeData - - -class WorkflowAgentNodeValidationError(ValueError): - """Raised when a Workflow Agent v2 node cannot be executed or published.""" - - -class WorkflowAgentNodeValidator: - """Validate Agent v2 workflow nodes against graph topology and persisted bindings.""" - - _LOCKED_AGENT_SOUL_KEYS = frozenset( - { - "agent_soul", - "soul", - "prompt", - "system_prompt", - "skills", - "files", - "tools", - "dify_tools", - "cli_tools", - "knowledge", - "env", - "environment", - "sandbox", - "sandbox_provider", - "memory", - "memory_strategy", - "model", - "app_features", - "app_variables", - "misc_legacy", - } - ) - _SUPPORTED_HUMAN_CONTACT_CHANNELS = frozenset({"email", "slack", "web_app", "webapp", "chat"}) - _AGENTIC_TOOL_CONFIG_KEYS = ("agentic_mode", "agenticMode", "agentic") - _MANUAL_TOOL_AGENTIC_STATES = frozenset({"manual", "expert", "expert_zone", "exited"}) - _DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"}) - - @classmethod - def validate_draft_workflow(cls, *, session: Session, workflow: Workflow) -> None: - cls._validate_workflow( - session=session, - workflow=workflow, - require_binding=False, - require_agent_model=False, - validate_previous_node_topology=False, - ) - - @classmethod - def validate_published_workflow(cls, *, session: Session, workflow: Workflow) -> None: - cls._validate_workflow( - session=session, - workflow=workflow, - require_binding=True, - require_agent_model=True, - validate_previous_node_topology=True, - ) - - @classmethod - def _validate_workflow( - cls, - *, - session: Session, - workflow: Workflow, - require_binding: bool, - require_agent_model: bool, - validate_previous_node_topology: bool, - ) -> None: - graph = workflow.graph_dict - topology = _WorkflowGraphTopology.from_graph(graph) if validate_previous_node_topology else None - for node_id, node_data in cls.iter_agent_v2_nodes(graph): - cls._validate_node_schema(node_id=node_id, node_data=node_data) - binding = cls._find_binding( - session=session, - tenant_id=workflow.tenant_id, - app_id=workflow.app_id, - workflow_id=workflow.id, - node_id=node_id, - ) - if binding is None: - if require_binding: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {node_id} requires a binding before publishing." - ) - continue - if ( - require_binding - and node_data.get("error_strategy") == ErrorStrategy.DEFAULT_VALUE - and WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict).output_routes.enabled - ): - raise WorkflowAgentNodeValidationError( - "Output routes do not support the node-level default-value error strategy." - ) - cls.validate_binding( - session=session, - binding=binding, - topology=topology, - require_agent_model=require_agent_model, - ) - - if require_binding: - for node_id, node_data in cls.iter_tool_nodes(graph): - cls._validate_tool_node_agentic_mode(node_id=node_id, node_data=node_data) - - @classmethod - def validate_binding( - cls, - *, - session: Session, - binding: WorkflowAgentNodeBinding, - topology: _WorkflowGraphTopology | None = None, - require_agent_model: bool = True, - ) -> None: - """Validate binding ownership, publication state, Agent Soul, and node-job references.""" - - if binding.agent_id is None: - raise WorkflowAgentNodeValidationError(f"Workflow Agent node {binding.node_id} is missing agent binding.") - - agent_stmt = select(Agent).where( - Agent.tenant_id == binding.tenant_id, - Agent.id == binding.agent_id, - ) - if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT: - agent_stmt = agent_stmt.where( - Agent.scope == AgentScope.ROSTER, - workflow_callable_active_snapshot_filter(), - ) - agent = session.scalar(agent_stmt.limit(1)) - if agent is None or agent.status == AgentStatus.ARCHIVED: - availability = ( - "an unavailable or unpublished roster agent" - if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT - else "an unavailable agent" - ) - raise WorkflowAgentNodeValidationError(f"Workflow Agent node {binding.node_id} references {availability}.") - - snapshot_id = ( - agent.active_config_snapshot_id - if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT - else binding.current_snapshot_id - ) - if snapshot_id is None: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} is missing config snapshot binding." - ) - - snapshot = session.scalar( - select(AgentConfigSnapshot) - .where( - AgentConfigSnapshot.tenant_id == binding.tenant_id, - AgentConfigSnapshot.agent_id == agent.id, - AgentConfigSnapshot.id == snapshot_id, - ) - .limit(1) - ) - if snapshot is None: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} references a missing config snapshot." - ) - - agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) - if require_agent_model and agent_soul.model is None: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} requires Agent Soul model config." - ) - cls._validate_agent_soul_env(binding=binding, agent_soul=agent_soul) - cls._validate_agent_soul_tools(binding=binding, agent_soul=agent_soul) - cls._validate_agent_soul_knowledge(session=session, binding=binding, agent_soul=agent_soul) - node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict) - if require_agent_model: - node_job.output_routes.validate_for_execution() - cls.validate_node_job(session=session, binding=binding, node_job=node_job, topology=topology) - - @classmethod - def validate_node_job( - cls, - *, - session: Session, - binding: WorkflowAgentNodeBinding, - node_job: WorkflowNodeJobConfig, - topology: _WorkflowGraphTopology | None = None, - ) -> None: - cls._validate_locked_agent_soul_not_overridden(binding=binding, node_job=node_job) - - output_names: set[str] = set() - for output in node_job.declared_outputs: - if output.name in output_names: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} has duplicate output name {output.name}." - ) - output_names.add(output.name) - # Stage 4 §4.3: declared output carries a single optional check, gated by - # ``check.enabled``. Only enabled checks need their benchmark file resolved. - if output.check is not None and output.check.enabled and output.check.benchmark_file_ref is not None: - cls._validate_file_ref( - session=session, - binding=binding, - file_ref=output.check.benchmark_file_ref, - ref_context=f"output {output.name} benchmark file", - ) - - route_refs = ( - workflow_previous_node_output_refs_from_selectors( - extract_workflow_node_output_selectors("\n".join(route.name for route in node_job.output_routes.routes)) - ) - if node_job.output_routes.enabled - else [] - ) - for ref in [*node_job.previous_node_output_refs, *route_refs]: - selector = cls.selector_from_ref(ref) - if selector is None: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} has invalid previous node output ref." - ) - if len(selector) < 2: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} has incomplete previous node output ref." - ) - if topology is None: - continue - source_node_id = selector[0] - if not topology.has_node(source_node_id): - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} references missing previous node {source_node_id}." - ) - if not topology.is_upstream(source_node_id=source_node_id, target_node_id=binding.node_id): - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} references non-upstream previous node {source_node_id}." - ) - - for human_ref in node_job.human_contacts: - cls._validate_human_ref(binding=binding, human_ref=human_ref) - - file_refs = node_job.metadata.file_refs - if isinstance(file_refs, list): - for file_ref in file_refs: - cls._validate_file_ref( - session=session, - binding=binding, - file_ref=file_ref, - ref_context="metadata file ref", - ) - - @staticmethod - def iter_agent_v2_nodes(graph_dict: Mapping[str, Any]) -> Iterator[tuple[str, Mapping[str, Any]]]: - nodes = graph_dict.get("nodes") - if not isinstance(nodes, list): - return - for node in nodes: - if not isinstance(node, Mapping): - continue - node_id = node.get("id") - node_data = node.get("data") - if not isinstance(node_id, str) or not isinstance(node_data, Mapping): - continue - if is_dify_agent_node_data(node_data): - yield node_id, node_data - - @staticmethod - def iter_tool_nodes(graph_dict: Mapping[str, Any]) -> Iterator[tuple[str, Mapping[str, Any]]]: - nodes = graph_dict.get("nodes") - if not isinstance(nodes, list): - return - for node in nodes: - if not isinstance(node, Mapping): - continue - node_id = node.get("id") - node_data = node.get("data") - if not isinstance(node_id, str) or not isinstance(node_data, Mapping): - continue - if node_data.get("type") == BuiltinNodeTypes.TOOL: - yield node_id, node_data - - @staticmethod - def selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> list[str] | None: - for key in ("selector", "variable_selector", "value_selector"): - value = ref.get(key) - if isinstance(value, list) and all(isinstance(item, str) for item in value): - return value - node_id = ref.get("node_id") - output_name = ref.get("output") or ref.get("name") or ref.get("variable") or ref.get("key") - if isinstance(node_id, str) and isinstance(output_name, str): - return [node_id, output_name] - return None - - @staticmethod - def _validate_node_schema(*, node_id: str, node_data: Mapping[str, Any]) -> None: - try: - DifyAgentNodeData.model_validate(node_data) - except ValueError as exc: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {node_id} has invalid Agent v2 node schema: {exc}" - ) from exc - - @classmethod - def _validate_locked_agent_soul_not_overridden( - cls, - *, - binding: WorkflowAgentNodeBinding, - node_job: WorkflowNodeJobConfig, - ) -> None: - forbidden_paths = cls._find_locked_agent_soul_paths( - node_job.metadata.model_dump(mode="python", exclude_none=True) - ) - if forbidden_paths: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} cannot override locked Agent Soul fields: " - f"{', '.join(sorted(forbidden_paths))}." - ) - - @classmethod - def _find_locked_agent_soul_paths(cls, value: Any, *, path: str = "metadata") -> set[str]: - if not isinstance(value, Mapping): - return set() - forbidden: set[str] = set() - for key, item in value.items(): - key_text = str(key) - if key_text in cls._LOCKED_AGENT_SOUL_KEYS: - forbidden.add(f"{path}.{key_text}") - forbidden.update(cls._find_locked_agent_soul_paths(item, path=f"{path}.{key_text}")) - return forbidden - - @classmethod - def _validate_human_ref( - cls, - *, - binding: WorkflowAgentNodeBinding, - human_ref: AgentHumanContactConfig, - ) -> None: - contact_id = human_ref.get("contact_id") or human_ref.get("human_id") or human_ref.get("id") - if not isinstance(contact_id, str) or not contact_id: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} has invalid human contact ref." - ) - - tenant_id = human_ref.get("tenant_id") - if tenant_id is not None and tenant_id != binding.tenant_id: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} references out-of-scope human contact {contact_id}." - ) - - channel = human_ref.get("channel") or human_ref.get("method") or human_ref.get("contact_method") - if channel is not None and channel not in cls._SUPPORTED_HUMAN_CONTACT_CHANNELS: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} references unsupported human contact channel {channel}." - ) - - @classmethod - def _validate_agent_soul_tools( - cls, - *, - binding: WorkflowAgentNodeBinding, - agent_soul: AgentSoulConfig, - ) -> None: - exposed_names: set[str] = set() - for tool in agent_soul.tools.dify_tools: - if not tool.enabled: - continue - # Provider-level entries (tool_name omitted = all tools of the - # provider) are deduped per provider here; the names they expand to - # are checked at runtime by the plugin tools builder. - provider_key = tool.provider_id or f"{tool.plugin_id}/{tool.provider}" - exposed_name = tool.tool_name or f"{provider_key}/*" - if exposed_name in exposed_names: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} has duplicate Dify Plugin Tool name {exposed_name}." - ) - exposed_names.add(exposed_name) - - cli_tool_names: set[str] = set() - for cli_tool in agent_soul.tools.cli_tools: - if not cli_tool.enabled: - continue - if cls._permission_denied(cli_tool.model_dump(mode="python", exclude_none=True, exclude_defaults=True)): - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} has unauthorized CLI Tool config." - ) - if cli_tool.pre_authorized is False: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} has unauthorized CLI Tool config." - ) - if cls._dangerous_cli_without_acknowledgement(cli_tool.model_dump(mode="python")): - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} has unacknowledged dangerous CLI Tool config." - ) - name = cli_tool.get("name") or cli_tool.get("tool_name") or cli_tool.get("label") - if not isinstance(name, str) or not name.strip(): - continue - normalized_name = name.strip() - if normalized_name in cli_tool_names: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} has duplicate CLI Tool name {normalized_name}." - ) - cli_tool_names.add(normalized_name) - - @classmethod - def _validate_agent_soul_knowledge( - cls, - *, - session: Session, - binding: WorkflowAgentNodeBinding, - agent_soul: AgentSoulConfig, - ) -> None: - """Validate knowledge set dataset rows against the publishing tenant.""" - missing_ids = list_missing_tenant_knowledge_dataset_ids( - session=session, - tenant_id=binding.tenant_id, - agent_soul=agent_soul, - ) - if missing_ids: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} references missing or out-of-scope knowledge datasets: " - f"{', '.join(missing_ids)}." - ) - - @classmethod - def _validate_agent_soul_env( - cls, - *, - binding: WorkflowAgentNodeBinding, - agent_soul: AgentSoulConfig, - ) -> None: - seen_names: set[str] = set() - cls._validate_env_entries( - binding=binding, - seen_names=seen_names, - variables=agent_soul.env.variables, - secret_refs=agent_soul.env.secret_refs, - label="agent", - ) - for cli_tool in agent_soul.tools.cli_tools: - if not cli_tool.enabled: - continue - name = cli_tool.get("name") or cli_tool.get("tool_name") or cli_tool.get("label") or "" - cls._validate_env_entries( - binding=binding, - seen_names=seen_names, - variables=cli_tool.env.variables, - secret_refs=cli_tool.env.secret_refs, - label=f"CLI Tool {name}", - ) - - @classmethod - def _validate_env_entries( - cls, - *, - binding: WorkflowAgentNodeBinding, - seen_names: set[str], - variables: list[Any], - secret_refs: list[Any], - label: str, - ) -> None: - for env_var in variables: - name = cls._env_name(env_var) - if not name: - continue - if name in seen_names: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} has duplicate env/secret name {name}." - ) - seen_names.add(name) - for secret_ref in secret_refs: - name = cls._env_name(secret_ref) - if not name: - continue - if cls._permission_denied(secret_ref.model_dump(mode="python", exclude_none=True, exclude_defaults=True)): - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} has unauthorized secret reference {name} in {label}." - ) - if name in seen_names: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} has duplicate env/secret name {name}." - ) - seen_names.add(name) - - @staticmethod - def _env_name(value: Any) -> str | None: - if hasattr(value, "get"): - for key in ("name", "key", "env_name", "variable"): - item = value.get(key) - if isinstance(item, str) and item.strip(): - return item.strip() - return None - - @classmethod - def _validate_tool_node_agentic_mode(cls, *, node_id: str, node_data: Mapping[str, Any]) -> None: - agentic_config = cls._extract_tool_agentic_config(node_data) - if agentic_config is None or agentic_config is False: - return - if agentic_config is True: - raise WorkflowAgentNodeValidationError( - f"Tool node {node_id} has incomplete agentic mode config for publishing." - ) - if not isinstance(agentic_config, Mapping): - raise WorkflowAgentNodeValidationError(f"Tool node {node_id} has invalid agentic mode config.") - - if agentic_config.get("enabled") is False: - return - if cls._permission_denied(agentic_config): - raise WorkflowAgentNodeValidationError(f"Tool node {node_id} has unauthorized agentic mode config.") - if agentic_config.get("complete") is False: - raise WorkflowAgentNodeValidationError( - f"Tool node {node_id} has incomplete agentic mode config for publishing." - ) - - state = agentic_config.get("state") or agentic_config.get("mode") - if isinstance(state, str) and state in cls._MANUAL_TOOL_AGENTIC_STATES: - return - - if cls._extract_agentic_parameter_draft(agentic_config) is None and not cls._tool_node_has_manual_parameters( - node_data - ): - raise WorkflowAgentNodeValidationError( - f"Tool node {node_id} has incomplete agentic mode config for publishing." - ) - - @classmethod - def _extract_tool_agentic_config(cls, node_data: Mapping[str, Any]) -> object | None: - for key in cls._AGENTIC_TOOL_CONFIG_KEYS: - if key in node_data: - return node_data[key] - return None - - @staticmethod - def _extract_agentic_parameter_draft(agentic_config: Mapping[str, Any]) -> Mapping[str, Any] | None: - for key in ("parameter_draft", "parameters_draft", "draft_parameters", "inferred_parameters", "parameters"): - value = agentic_config.get(key) - if isinstance(value, Mapping) and value: - return value - return None - - @staticmethod - def _tool_node_has_manual_parameters(node_data: Mapping[str, Any]) -> bool: - for key in ("tool_parameters", "tool_configurations"): - value = node_data.get(key) - if isinstance(value, Mapping) and value: - return True - return False - - @classmethod - def _permission_denied(cls, value: Mapping[str, Any]) -> bool: - permission = value.get("permission") - if isinstance(permission, Mapping): - allowed = permission.get("allowed") - if allowed is False: - return True - status = permission.get("status") or permission.get("state") - if isinstance(status, str) and status in cls._DENIED_PERMISSION_STATUSES: - return True - status = value.get("permission_status") or value.get("authorization_status") - return isinstance(status, str) and status in cls._DENIED_PERMISSION_STATUSES - - @staticmethod - def _dangerous_cli_without_acknowledgement(value: Mapping[str, Any]) -> bool: - dangerous = any(value.get(key) is True for key in ("dangerous", "dangerous_command", "requires_confirmation")) - risk_level = value.get("risk_level") - if isinstance(risk_level, str) and risk_level == "dangerous": - dangerous = True - if not dangerous: - return False - return not any( - value.get(key) is True - for key in ("dangerous_acknowledged", "dangerous_accepted", "risk_accepted", "approved") - ) - - @staticmethod - def _validate_file_ref( - *, - session: Session, - binding: WorkflowAgentNodeBinding, - file_ref: AgentFileRefConfig, - ref_context: str, - ) -> None: - tenant_id = file_ref.get("tenant_id") - if tenant_id is not None and tenant_id != binding.tenant_id: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} references out-of-scope {ref_context}." - ) - - upload_file_id = ( - file_ref.get("upload_file_id") or file_ref.get("file_id") or file_ref.get("id") or file_ref.get("reference") - ) - if upload_file_id is None and (file_ref.get("url") or file_ref.get("remote_url")): - return - if not isinstance(upload_file_id, str) or not upload_file_id: - raise WorkflowAgentNodeValidationError(f"Workflow Agent node {binding.node_id} has invalid {ref_context}.") - - upload_file = session.scalar( - select(UploadFile) - .where( - UploadFile.tenant_id == binding.tenant_id, - UploadFile.id == upload_file_id, - ) - .limit(1) - ) - if upload_file is None: - raise WorkflowAgentNodeValidationError( - f"Workflow Agent node {binding.node_id} references missing or out-of-scope {ref_context}." - ) - - @staticmethod - def _find_binding( - *, - session: Session, - tenant_id: str, - app_id: str, - workflow_id: str, - node_id: str, - ) -> WorkflowAgentNodeBinding | None: - return session.scalar( - select(WorkflowAgentNodeBinding) - .where( - WorkflowAgentNodeBinding.tenant_id == tenant_id, - WorkflowAgentNodeBinding.app_id == app_id, - WorkflowAgentNodeBinding.workflow_id == workflow_id, - WorkflowAgentNodeBinding.node_id == node_id, - ) - .limit(1) - ) - - -# Extracted to core/workflow/graph_topology.py (shared with the agent-composer -# candidates endpoint, ENG-615); kept as a private alias for existing call sites. -_WorkflowGraphTopology = WorkflowGraphTopology diff --git a/api/core/workflow/nodes/human_input/boundary.py b/api/core/workflow/nodes/human_input/boundary.py deleted file mode 100644 index b15bdf9f74f947..00000000000000 --- a/api/core/workflow/nodes/human_input/boundary.py +++ /dev/null @@ -1,138 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable, Sequence - -from core.repositories.human_input_repository import HumanInputFormSubmissionRepository -from core.workflow.human_input_policy import resolve_variable_select_input_options -from core.workflow.system_variables import SystemVariableKey, get_system_text -from graphon.entities.pause_reason import HitlRequired, SchedulingPause -from graphon.enums import BuiltinNodeTypes -from graphon.filters import GraphEventFilterContext -from graphon.graph_events import ( - GraphEngineEvent, - NodeRunHumanInputFormFilledEvent, - NodeRunHumanInputFormTimeoutEvent, - NodeRunStartedEvent, - NodeRunSucceededEvent, -) -from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool - -from .constants import OUTPUT_FIELD_ACTION_ID, OUTPUT_FIELD_ACTION_VALUE, OUTPUT_FIELD_RENDERED_CONTENT, TIMEOUT_HANDLE -from .pause_reason import HumanInputRequired, PauseReason -from .session_binding import default_session_binding - - -class HumanInputPauseReasonResolutionError(LookupError): - """Raised when a graph pause reason cannot be resolved into Dify-owned form state.""" - - -class HumanInputFormEventFilter: - """Adapt HITL callback results into Dify's form lifecycle event stream.""" - - def __init__(self, *, form_repository: HumanInputFormSubmissionRepository) -> None: - self._form_repository = form_repository - self._node_titles: dict[str, str] = {} - self._app_id: str | None = None - - @property - def filter_id(self) -> str: - return "dify-human-input-form-events" - - def initialize(self, context: GraphEventFilterContext) -> None: - self._node_titles.clear() - self._app_id = get_system_text(context.runtime_state.variable_pool, SystemVariableKey.APP_ID) - - def on_event(self, event: GraphEngineEvent) -> Iterable[GraphEngineEvent]: - if isinstance(event, NodeRunStartedEvent) and event.node_type == BuiltinNodeTypes.HUMAN_INPUT: - self._node_titles[event.id] = event.node_title - elif isinstance(event, NodeRunSucceededEvent) and event.node_type == BuiltinNodeTypes.HUMAN_INPUT: - yield self._completion_event(event) - - yield event - - def flush(self) -> Iterable[GraphEngineEvent]: - return () - - def _completion_event( - self, event: NodeRunSucceededEvent - ) -> NodeRunHumanInputFormFilledEvent | NodeRunHumanInputFormTimeoutEvent: - # Titles are present only on start events. Key by execution ID so loop - # iterations and concurrent executions do not share completion metadata. - node_title = self._node_titles.pop(event.id) - result = event.node_run_result - if result.edge_source_handle == TIMEOUT_HANDLE: - # The callback's timeout result omits the deadline. Resolve the form - # by its bound execution ID rather than choosing a form by node ID. - form = self._form_repository.get_by_form_id(event.id) - if form is None or form.app_id != self._app_id or form.node_id != event.node_id: - raise ValueError(f"Cannot resolve timed-out human input form for node execution {event.id}") - return NodeRunHumanInputFormTimeoutEvent( - id=event.id, - node_id=event.node_id, - node_type=event.node_type, - node_title=node_title, - node_version=event.node_version, - in_iteration_id=event.in_iteration_id, - in_loop_id=event.in_loop_id, - expiration_time=form.expiration_time, - ) - - return NodeRunHumanInputFormFilledEvent( - id=event.id, - node_id=event.node_id, - node_type=event.node_type, - node_title=node_title, - node_version=event.node_version, - in_iteration_id=event.in_iteration_id, - in_loop_id=event.in_loop_id, - rendered_content=result.outputs[OUTPUT_FIELD_RENDERED_CONTENT].text, - action_id=result.outputs[OUTPUT_FIELD_ACTION_ID].text, - action_text=result.outputs[OUTPUT_FIELD_ACTION_VALUE].text, - submitted_data=result.inputs, - ) - - -def enrich_graph_pause_reasons( - *, - reasons: Sequence[HitlRequired | PauseReason], - form_repository: HumanInputFormSubmissionRepository, - variable_pool: ReadOnlyVariablePool | None, -) -> list[PauseReason]: - enriched: list[PauseReason] = [] - for reason in reasons: - if isinstance(reason, HitlRequired): - enriched_reason = _enrich_hitl_required( - reason=reason, - form_repository=form_repository, - variable_pool=variable_pool, - ) - if enriched_reason is not None: - enriched.append(enriched_reason) - continue - if isinstance(reason, HumanInputRequired | SchedulingPause): - enriched.append(reason) - return enriched - - -def _enrich_hitl_required( - *, - reason: HitlRequired, - form_repository: HumanInputFormSubmissionRepository, - variable_pool: ReadOnlyVariablePool | None, -) -> HumanInputRequired: - form_id = default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id) - record = form_repository.get_by_form_id(form_id) - if record is None: - raise HumanInputPauseReasonResolutionError( - f"missing human input form while enriching pause reason: form_id={form_id}, session_id={reason.session_id}" - ) - - return HumanInputRequired( - form_id=record.form_id, - form_content=record.rendered_content, - inputs=resolve_variable_select_input_options(record.definition.inputs, variable_pool=variable_pool), - actions=list(record.definition.user_actions), - node_id=reason.node_id, - node_title=reason.node_title or record.definition.node_title or record.node_id, - resolved_default_values=dict(record.definition.default_values), - ) diff --git a/api/core/workflow/nodes/human_input/callback.py b/api/core/workflow/nodes/human_input/callback.py deleted file mode 100644 index 5ea992c2d7dc3a..00000000000000 --- a/api/core/workflow/nodes/human_input/callback.py +++ /dev/null @@ -1,321 +0,0 @@ -from __future__ import annotations - -import json -import logging -from collections.abc import Callable, Mapping, Sequence -from datetime import datetime, timedelta -from typing import Any - -from configs import dify_config -from core.repositories.human_input_repository import FormCreateParams, HumanInputFormEntity, HumanInputFormRepository -from core.workflow.node_runtime import DifyFileReferenceFactory -from enums.human_input import HumanInputFormStatus -from graphon.nodes.human_input.entities import Completed, Expired, HITLContext, HITLDecision, PauseRequested -from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool -from graphon.variables.factory import build_segment -from graphon.variables.segments import Segment -from graphon.variables.template_resolution import convert_template -from libs.datetime_utils import ensure_naive_utc, naive_utc_now -from models.human_input_delivery import DeliveryChannelConfig -from models.human_input_entities import ( - FileInputConfig, - FileListInputConfig, - FormInputConfig, - HumanInputNodeData, - ParagraphInputConfig, - SelectInputConfig, -) - -from .constants import OUTPUT_FIELD_ACTION_ID, OUTPUT_FIELD_ACTION_VALUE, OUTPUT_FIELD_RENDERED_CONTENT, TIMEOUT_HANDLE -from .session_binding import default_session_binding - -logger = logging.getLogger(__name__) - - -def render_form_content_before_submission( - node_data: HumanInputNodeData, - *, - variable_pool: ReadOnlyVariablePool, -) -> str: - """Process form content by substituting runtime variables before pause.""" - rendered_form_content = convert_template(variable_pool, node_data.form_content) - return rendered_form_content.markdown - - -def resolve_default_values( - node_data: HumanInputNodeData, - *, - variable_pool: ReadOnlyVariablePool, -) -> Mapping[str, Any]: - resolved_defaults: dict[str, Any] = {} - for form_input in node_data.inputs: - resolved_default = form_input.resolve_default_value(variable_pool) - if resolved_default is None: - continue - resolved_defaults[form_input.output_variable_name] = resolved_default.to_object() - return resolved_defaults - - -class DifyHITLCallback: - """Bridge Dify human-input semantics onto graphon HITL decisions. - - The boundary preserves Dify's split between node timeout and global - expiration: only explicit node timeout resumes along the timeout handle, - while global expiration is treated as an invalid resume state. - """ - - pause_requested_type = PauseRequested - - def __init__( - self, - *, - form_repository: HumanInputFormRepository, - node_data: HumanInputNodeData, - workflow_execution_id: str | None = None, - conversation_id: str | None = None, - delivery_methods: Sequence[DeliveryChannelConfig] = (), - display_in_ui: bool = False, - file_reference_factory: DifyFileReferenceFactory | None = None, - execution_id_getter: Callable[[], str | None] | None = None, - ) -> None: - self._form_repository = form_repository - self._session_binding = default_session_binding - self._node_data = node_data - self._workflow_execution_id = workflow_execution_id - self._conversation_id = conversation_id - self._delivery_methods = tuple(delivery_methods) - self._display_in_ui = display_in_ui - self._file_reference_factory = file_reference_factory - self._execution_id_getter = execution_id_getter - - def __call__(self, ctx: HITLContext) -> HITLDecision: - form_id = self._execution_id_getter() if self._execution_id_getter is not None else None - form = ( - self._form_repository.get_form(ctx.node_id, form_id=form_id) - if form_id is not None - else self._form_repository.get_form(ctx.node_id) - ) - if form is None: - created = self._create_form(ctx, form_id=form_id) - return PauseRequested(session_id=self._session_binding.issue_session_id_for_form(form_id=created.id)) - - status = self._normalize_status(form.status) - if status == HumanInputFormStatus.TIMEOUT.value: - return Expired( - selected_handle=TIMEOUT_HANDLE, - outputs=self._build_special_outputs( - action_id="", - action_value="", - rendered_content=form.rendered_content, - ), - ) - - if status == HumanInputFormStatus.EXPIRED.value: - msg = f"cannot resume globally expired human input form, form_id={form.id}" - raise AssertionError(msg) - - if not form.submitted: - if status == HumanInputFormStatus.WAITING.value and self._is_past_global_deadline(form.created_at): - msg = f"cannot resume waiting human input form after global timeout, form_id={form.id}" - raise AssertionError(msg) - if self._is_past_node_deadline(form.expiration_time): - return Expired( - selected_handle=TIMEOUT_HANDLE, - outputs=self._build_special_outputs( - action_id="", - action_value="", - rendered_content=form.rendered_content, - ), - ) - return PauseRequested(session_id=self._session_binding.issue_session_id_for_form(form_id=form.id)) - - selected_action_id = form.selected_action_id - if selected_action_id is None: - msg = f"selected_action_id should not be None when form submitted, form_id={form.id}" - raise AssertionError(msg) - - submitted_data = self._restore_submitted_data(submitted_data=form.submitted_data or {}) - rendered_content = self.render_form_content_with_outputs( - form.rendered_content, - submitted_data, - self._node_data.outputs_field_names(), - self._node_data.inputs, - ) - outputs = dict(submitted_data) - outputs.update( - self._build_special_outputs( - action_id=selected_action_id, - action_value=self._node_data.must_resolve_action_value(selected_action_id), - rendered_content=rendered_content, - ) - ) - return Completed( - selected_handle=selected_action_id, - inputs=submitted_data, - outputs=outputs, - ) - - def _create_form(self, ctx: HITLContext, *, form_id: str | None = None) -> HumanInputFormEntity: - params = FormCreateParams( - workflow_execution_id=self._workflow_execution_id or ctx.workflow_execution_id, - conversation_id=self._conversation_id, - node_id=ctx.node_id, - form_config=self._node_data, - rendered_content=render_form_content_before_submission( - self._node_data, - variable_pool=ctx.variable_pool, - ), - delivery_methods=self._delivery_methods, - display_in_ui=self._display_in_ui, - resolved_default_values=dict( - resolve_default_values( - self._node_data, - variable_pool=ctx.variable_pool, - ) - ), - form_id=form_id, - ) - return self._form_repository.create_form(params) - - @staticmethod - def _normalize_status(status: HumanInputFormStatus | str) -> str: - if isinstance(status, HumanInputFormStatus): - return status.value - return status - - @staticmethod - def _is_past_node_deadline(expiration_time: datetime) -> bool: - expiration_time = ensure_naive_utc(expiration_time) - return expiration_time <= naive_utc_now() - - @staticmethod - def _is_past_global_deadline(created_at: datetime) -> bool: - global_timeout_seconds = dify_config.HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS - if global_timeout_seconds <= 0: - return False - global_deadline = ensure_naive_utc(created_at) + timedelta(seconds=global_timeout_seconds) - return global_deadline <= naive_utc_now() - - @staticmethod - def render_form_content_with_outputs( - form_content: str, - outputs: Mapping[str, Segment], - field_names: Sequence[str], - form_inputs: Sequence[FormInputConfig] | None = None, - ) -> str: - """Replace {{#$output.xxx#}} placeholders with submitted values. - - Text inputs render their submitted value directly. File inputs render as - stable placeholders so the final content stays readable and does not - inline transport metadata. - - Returns: - the interplated form content - """ - inputs_by_name: dict[str, FormInputConfig] = {} - if form_inputs is not None: - inputs_by_name = {form_input.output_variable_name: form_input for form_input in form_inputs} - - rendered_content = form_content - for field_name in field_names: - placeholder = "{{#$output." + field_name + "#}}" - replacement = DifyHITLCallback._render_output_placeholder_value( - value=outputs.get(field_name), - form_input=inputs_by_name.get(field_name), - ) - rendered_content = rendered_content.replace(placeholder, replacement) - return rendered_content - - @staticmethod - def _render_output_placeholder_value( - *, - value: Any, - form_input: FormInputConfig | None, - ) -> str: - if isinstance(value, Segment): - value = value.to_object() - - if value is None: - return "" - - if isinstance(form_input, FileInputConfig): - return "[file]" - - if isinstance(form_input, FileListInputConfig): - file_count = 0 - if isinstance(value, Sequence) and not isinstance(value, str | bytes): - file_count = len(value) - return f"[{file_count} files]" - - if isinstance(form_input, ParagraphInputConfig | SelectInputConfig): - return str(value) - - if isinstance(value, dict | list): - return json.dumps(value, ensure_ascii=False) - - return str(value) - - def _restore_submitted_data( - self, - *, - submitted_data: Mapping[str, Any], - ) -> dict[str, Segment]: - """Reconstruct graphon runtime values from validated submission payloads.""" - restored_data: dict[str, Segment] = {} - inputs_by_name = {form_input.output_variable_name: form_input for form_input in self._node_data.inputs} - - for name, value in submitted_data.items(): - form_input = inputs_by_name.get(name) - if form_input is None: - logger.error("unexpected form data in submitted data, key=%s", name) - continue - restored_data[name] = build_segment(self._restore_submitted_value(input_config=form_input, value=value)) - - return restored_data - - def _restore_submitted_value( - self, - *, - input_config: FormInputConfig, - value: Any, - ) -> Any: - if isinstance(input_config, FileInputConfig): - if not isinstance(value, Mapping): - raise ValueError( - "HumanInput file submission must be persisted as a mapping, " - f"output_variable_name={input_config.output_variable_name}" - ) - return self._build_file_reference(mapping=value) - - if isinstance(input_config, FileListInputConfig): - if not isinstance(value, list): - raise ValueError( - "HumanInput file-list submission must be persisted as a list, " - f"output_variable_name={input_config.output_variable_name}" - ) - if any(not isinstance(item, Mapping) for item in value): - raise ValueError( - "HumanInput file-list submission must contain mappings, " - f"output_variable_name={input_config.output_variable_name}" - ) - return [self._build_file_reference(mapping=item) for item in value] - - return value - - def _build_file_reference(self, *, mapping: Mapping[str, Any]) -> Any: - if self._file_reference_factory is None: - raise ValueError("file_reference_factory is required to restore file submissions") - return self._file_reference_factory.build_from_mapping(mapping=mapping) - - @staticmethod - def _build_special_outputs( - *, - action_id: str, - action_value: str, - rendered_content: str, - ) -> dict[str, Segment]: - return { - OUTPUT_FIELD_ACTION_ID: build_segment(action_id), - OUTPUT_FIELD_ACTION_VALUE: build_segment(action_value), - OUTPUT_FIELD_RENDERED_CONTENT: build_segment(rendered_content), - } diff --git a/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py b/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py deleted file mode 100644 index a3547fbcf7687c..00000000000000 --- a/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py +++ /dev/null @@ -1,364 +0,0 @@ -"""Knowledge retrieval workflow node implementation. - -This node now lives under ``core.workflow.nodes`` and is discovered directly by -the workflow node registry. -""" - -import logging -from collections.abc import Mapping, Sequence -from typing import TYPE_CHECKING, Any, Literal, override - -from sqlalchemy.orm import Session - -from core.app.app_config.entities import DatasetRetrieveConfigEntity -from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext -from core.credit_usage import CreditUsageAppType -from core.db.session_factory import session_factory -from core.rag.data_post_processor.data_post_processor import RerankingModelDict, WeightsDict -from core.rag.retrieval.dataset_retrieval import DatasetRetrieval -from core.workflow.file_reference import parse_file_reference -from graphon.entities import GraphInitParams -from graphon.enums import ( - BuiltinNodeTypes, - WorkflowNodeExecutionMetadataKey, - WorkflowNodeExecutionStatus, -) -from graphon.model_runtime.entities.llm_entities import LLMUsage -from graphon.model_runtime.utils.encoders import jsonable_encoder -from graphon.node_events import NodeRunResult -from graphon.nodes.base.node import Node -from graphon.variables import ( - ArrayFileSegment, - FileSegment, - StringSegment, -) -from graphon.variables.segments import ArrayObjectSegment -from graphon.variables.template_resolution import convert_template - -from .entities import ( - Condition, - KnowledgeRetrievalNodeData, - MetadataFilteringCondition, -) -from .exc import ( - KnowledgeRetrievalNodeError, - RateLimitExceededError, -) -from .retrieval import KnowledgeRetrievalRequest, Source - -if TYPE_CHECKING: - from graphon.file import File - from graphon.runtime import GraphRuntimeState - -logger = logging.getLogger(__name__) - - -def _normalize_metadata_filter_scalar(value: object) -> str | int | float | None: - if value is None or isinstance(value, (str, float)): - return value - if isinstance(value, int) and not isinstance(value, bool): - return value - return str(value) - - -def _normalize_metadata_filter_sequence_item(value: object) -> str: - return value if isinstance(value, str) else str(value) - - -class KnowledgeRetrievalNode(Node[KnowledgeRetrievalNodeData]): - node_type = BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL - - # Instance attributes specific to LLMNode. - # Output variable for file - _file_outputs: list["File"] - - def __init__( - self, - node_id: str, - data: KnowledgeRetrievalNodeData, - *, - graph_init_params: "GraphInitParams", - graph_runtime_state: "GraphRuntimeState", - session_maker=None, - ) -> None: - super().__init__( - node_id=node_id, - data=data, - graph_init_params=graph_init_params, - graph_runtime_state=graph_runtime_state, - ) - # LLM file outputs, used for MultiModal outputs. - self._file_outputs = [] - self._rag_retrieval = DatasetRetrieval() - self._session_maker = session_maker or session_factory.get_session_maker() - - @classmethod - @override - def version(cls): - return "1" - - @override - def _run(self) -> NodeRunResult: - usage = LLMUsage.empty_usage() - if not self._node_data.query_variable_selector and not self._node_data.query_attachment_selector: - return NodeRunResult( - status=WorkflowNodeExecutionStatus.SUCCEEDED, - inputs={}, - process_data={}, - outputs={}, - metadata={}, - llm_usage=usage, - ) - variables: dict[str, Any] = {} - # extract variables - if self._node_data.query_variable_selector: - variable = self.graph_runtime_state.variable_pool.get(self._node_data.query_variable_selector) - if not isinstance(variable, StringSegment): - return NodeRunResult( - status=WorkflowNodeExecutionStatus.FAILED, - inputs={}, - error="Query variable is not string type.", - ) - query = variable.value - variables["query"] = query - - if self._node_data.query_attachment_selector: - variable = self.graph_runtime_state.variable_pool.get(self._node_data.query_attachment_selector) - if not isinstance(variable, ArrayFileSegment) and not isinstance(variable, FileSegment): - return NodeRunResult( - status=WorkflowNodeExecutionStatus.FAILED, - inputs={}, - error="Attachments variable is not array file or file type.", - ) - if isinstance(variable, ArrayFileSegment): - variables["attachments"] = variable.value - else: - variables["attachments"] = [variable.value] - - try: - with self._session_maker() as session: - results, usage = self._fetch_dataset_retriever( - session=session, node_data=self._node_data, variables=variables - ) - outputs = {"result": ArrayObjectSegment(value=[item.model_dump(by_alias=True) for item in results])} - return NodeRunResult( - status=WorkflowNodeExecutionStatus.SUCCEEDED, - inputs=variables, - process_data={"usage": jsonable_encoder(usage)}, - outputs=outputs, - metadata={ - WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: usage.total_tokens, - WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: usage.total_price, - WorkflowNodeExecutionMetadataKey.CURRENCY: usage.currency, - }, - llm_usage=usage, - ) - except RateLimitExceededError as e: - logger.warning(e, exc_info=True) - return NodeRunResult( - status=WorkflowNodeExecutionStatus.FAILED, - inputs=variables, - error=str(e), - error_type=type(e).__name__, - llm_usage=usage, - ) - except KnowledgeRetrievalNodeError as e: - logger.warning("Error when running knowledge retrieval node", exc_info=True) - return NodeRunResult( - status=WorkflowNodeExecutionStatus.FAILED, - inputs=variables, - error=str(e), - error_type=type(e).__name__, - llm_usage=usage, - ) - # Temporary handle all exceptions from DatasetRetrieval class here. - except Exception as e: - logger.warning(e, exc_info=True) - return NodeRunResult( - status=WorkflowNodeExecutionStatus.FAILED, - inputs=variables, - error=str(e), - error_type=type(e).__name__, - llm_usage=usage, - ) - - def _fetch_dataset_retriever( - self, session: Session, node_data: KnowledgeRetrievalNodeData, variables: dict[str, Any] - ) -> tuple[list[Source], LLMUsage]: - dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY)) - self._rag_retrieval.set_request_metadata( - { - "app_id": dify_ctx.app_id, - "app_type": dify_ctx.app_type or CreditUsageAppType.UNKNOWN, - } - ) - dataset_ids = node_data.dataset_ids - query = variables.get("query") - attachments = variables.get("attachments") - retrieval_resource_list = [] - - metadata_filtering_mode: Literal["disabled", "automatic", "manual"] = "disabled" - if node_data.metadata_filtering_mode is not None: - metadata_filtering_mode = node_data.metadata_filtering_mode - - resolved_metadata_conditions = ( - self._resolve_metadata_filtering_conditions(node_data.metadata_filtering_conditions) - if node_data.metadata_filtering_conditions - else None - ) - - if str(node_data.retrieval_mode) == DatasetRetrieveConfigEntity.RetrieveStrategy.SINGLE and query: - # fetch model config - if node_data.single_retrieval_config is None: - raise ValueError("single_retrieval_config is required for single retrieval mode") - model = node_data.single_retrieval_config.model - retrieval_resource_list = self._rag_retrieval.knowledge_retrieval( - session=session, - request=KnowledgeRetrievalRequest( - tenant_id=dify_ctx.tenant_id, - user_id=dify_ctx.user_id, - app_id=dify_ctx.app_id, - user_from=dify_ctx.user_from.value, - dataset_ids=dataset_ids, - retrieval_mode=DatasetRetrieveConfigEntity.RetrieveStrategy.SINGLE.value, - completion_params=model.completion_params, - model_provider=model.provider, - model_mode=model.mode, - model_name=model.name, - metadata_model_config=node_data.metadata_model_config, - metadata_filtering_conditions=resolved_metadata_conditions, - metadata_filtering_mode=metadata_filtering_mode, - query=query, - ), - ) - elif str(node_data.retrieval_mode) == DatasetRetrieveConfigEntity.RetrieveStrategy.MULTIPLE: - if node_data.multiple_retrieval_config is None: - raise ValueError("multiple_retrieval_config is required") - reranking_model: RerankingModelDict | None = None - weights: WeightsDict | None = None - match node_data.multiple_retrieval_config.reranking_mode: - case "reranking_model": - if node_data.multiple_retrieval_config.reranking_model: - reranking_model = { - "reranking_provider_name": node_data.multiple_retrieval_config.reranking_model.provider, - "reranking_model_name": node_data.multiple_retrieval_config.reranking_model.model, - } - else: - reranking_model = None - weights = None - case "weighted_score": - if node_data.multiple_retrieval_config.weights is None: - raise ValueError("weights is required") - reranking_model = None - vector_setting = node_data.multiple_retrieval_config.weights.vector_setting - weights = { - "vector_setting": { - "vector_weight": vector_setting.vector_weight, - "embedding_provider_name": vector_setting.embedding_provider_name, - "embedding_model_name": vector_setting.embedding_model_name, - }, - "keyword_setting": { - "keyword_weight": node_data.multiple_retrieval_config.weights.keyword_setting.keyword_weight - }, - } - case _: - # Handle any other reranking_mode values - reranking_model = None - weights = None - - retrieval_resource_list = self._rag_retrieval.knowledge_retrieval( - session=session, - request=KnowledgeRetrievalRequest( - app_id=dify_ctx.app_id, - tenant_id=dify_ctx.tenant_id, - user_id=dify_ctx.user_id, - user_from=dify_ctx.user_from.value, - dataset_ids=dataset_ids, - query=query, - retrieval_mode=DatasetRetrieveConfigEntity.RetrieveStrategy.MULTIPLE.value, - top_k=node_data.multiple_retrieval_config.top_k, - score_threshold=node_data.multiple_retrieval_config.score_threshold - if node_data.multiple_retrieval_config.score_threshold is not None - else 0.0, - reranking_mode=node_data.multiple_retrieval_config.reranking_mode, - reranking_model=reranking_model, - weights=weights, - reranking_enable=node_data.multiple_retrieval_config.reranking_enable, - metadata_model_config=node_data.metadata_model_config, - metadata_filtering_conditions=resolved_metadata_conditions, - metadata_filtering_mode=metadata_filtering_mode, - attachment_ids=[ - parsed_reference.record_id - for attachment in attachments - if (parsed_reference := parse_file_reference(attachment.reference)) is not None - ] - if attachments - else None, - ), - ) - - usage = self._rag_retrieval.llm_usage - return retrieval_resource_list, usage - - def _resolve_metadata_filtering_conditions( - self, conditions: MetadataFilteringCondition - ) -> MetadataFilteringCondition: - if conditions.conditions is None: - return MetadataFilteringCondition( - logical_operator=conditions.logical_operator, - conditions=None, - ) - - variable_pool = self.graph_runtime_state.variable_pool - resolved_conditions: list[Condition] = [] - for cond in conditions.conditions or []: - value = cond.value - resolved_value: str | Sequence[str] | int | float | None - match value: - case str(): - segment_group = convert_template(variable_pool, value) - if len(segment_group.value) == 1: - resolved_value = _normalize_metadata_filter_scalar(segment_group.value[0].to_object()) - else: - resolved_value = segment_group.text - case _ if isinstance(value, Sequence) and all(isinstance(v, str) for v in value): - resolved_values: list[str] = [] - for v in value: - segment_group = convert_template(variable_pool, v) - if len(segment_group.value) == 1: - resolved_values.append( - _normalize_metadata_filter_sequence_item(segment_group.value[0].to_object()) - ) - else: - resolved_values.append(segment_group.text) - resolved_value = resolved_values - case _: - resolved_value = value - resolved_conditions.append( - Condition( - name=cond.name, - comparison_operator=cond.comparison_operator, - value=resolved_value, - ) - ) - return MetadataFilteringCondition( - logical_operator=conditions.logical_operator or "and", - conditions=resolved_conditions, - ) - - @classmethod - @override - def _extract_variable_selector_to_variable_mapping( - cls, - *, - graph_config: Mapping[str, Any], - node_id: str, - node_data: KnowledgeRetrievalNodeData, - ) -> Mapping[str, Sequence[str]]: - # graph_config is not used in this node type - variable_mapping = {} - if node_data.query_variable_selector: - variable_mapping[node_id + ".query"] = node_data.query_variable_selector - if node_data.query_attachment_selector: - variable_mapping[node_id + ".queryAttachment"] = node_data.query_attachment_selector - return variable_mapping diff --git a/api/core/workflow/nodes/trigger_webhook/node.py b/api/core/workflow/nodes/trigger_webhook/node.py deleted file mode 100644 index 64180275707ecf..00000000000000 --- a/api/core/workflow/nodes/trigger_webhook/node.py +++ /dev/null @@ -1,183 +0,0 @@ -import logging -from collections.abc import Mapping -from typing import Any, override - -from core.trigger.constants import TRIGGER_WEBHOOK_NODE_TYPE -from core.workflow.file_reference import resolve_file_record_id -from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID -from factories.variable_factory import build_segment_with_type -from graphon.enums import NodeExecutionType, WorkflowNodeExecutionStatus -from graphon.file import FileTransferMethod -from graphon.node_events import NodeRunResult -from graphon.nodes.base.node import Node -from graphon.nodes.protocols import FileReferenceFactoryProtocol -from graphon.variables.types import SegmentType -from graphon.variables.variables import FileVariable - -from .entities import ContentType, WebhookData - -logger = logging.getLogger(__name__) - - -class TriggerWebhookNode(Node[WebhookData]): - node_type = TRIGGER_WEBHOOK_NODE_TYPE - execution_type = NodeExecutionType.ROOT - - _file_reference_factory: FileReferenceFactoryProtocol - - @override - def post_init(self) -> None: - from core.workflow.node_runtime import DifyFileReferenceFactory - - self._file_reference_factory = DifyFileReferenceFactory(self.run_context) - - @classmethod - @override - def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]: - return { - "type": "webhook", - "config": { - "method": "get", - "content_type": "application/json", - "headers": [], - "params": [], - "body": [], - "async_mode": True, - "status_code": 200, - "response_body": "", - "timeout": 30, - }, - } - - @classmethod - @override - def version(cls) -> str: - return "1" - - @override - def _run(self) -> NodeRunResult: - """ - Run the webhook node. - - Like the start node, this simply takes the webhook data from the variable pool - and makes it available to downstream nodes. The actual webhook handling - happens in the trigger controller. - """ - # Get webhook data from variable pool (injected by Celery task) - webhook_inputs = dict(self.graph_runtime_state.variable_pool.get_by_prefix(self.id)) - - # Extract webhook-specific outputs based on node configuration - outputs = self._extract_configured_outputs(webhook_inputs) - system_inputs = self.graph_runtime_state.variable_pool.get_by_prefix(SYSTEM_VARIABLE_NODE_ID) - - for variable_name, value in system_inputs.items(): - outputs[f"{SYSTEM_VARIABLE_NODE_ID}.{variable_name}"] = value - return NodeRunResult( - status=WorkflowNodeExecutionStatus.SUCCEEDED, - inputs=webhook_inputs, - outputs=outputs, - ) - - def generate_file_var(self, param_name: str, file: dict[str, Any]): - file_id = resolve_file_record_id(file.get("reference") or file.get("related_id")) - transfer_method_value = file.get("transfer_method") - if transfer_method_value: - transfer_method = FileTransferMethod.value_of(transfer_method_value) - match transfer_method: - case FileTransferMethod.LOCAL_FILE | FileTransferMethod.REMOTE_URL: - file["upload_file_id"] = file_id - case FileTransferMethod.TOOL_FILE: - file["tool_file_id"] = file_id - case FileTransferMethod.DATASOURCE_FILE: - file["datasource_file_id"] = file_id - - try: - file_obj = self._file_reference_factory.build_from_mapping(mapping=file) - file_segment = build_segment_with_type(SegmentType.FILE, file_obj) - return FileVariable(name=param_name, value=file_segment.value, selector=[self.id, param_name]) - except ValueError: - logger.error( - "Failed to build FileVariable for webhook file parameter %s", - param_name, - exc_info=True, - ) - return None - - def _extract_configured_outputs(self, webhook_inputs: dict[str, Any]) -> dict[str, Any]: - """Extract outputs based on node configuration from webhook inputs.""" - outputs = {} - - # Get the raw webhook data (should be injected by Celery task) - webhook_data = webhook_inputs.get("webhook_data", {}) - - def _to_sanitized(name: str) -> str: - return name.replace("-", "_") - - def _get_normalized(mapping: dict[str, Any], key: str) -> Any: - if not isinstance(mapping, dict): - return None - if key in mapping: - return mapping[key] - alternate = key.replace("-", "_") if "-" in key else key.replace("_", "-") - if alternate in mapping: - return mapping[alternate] - return None - - # Extract configured headers (case-insensitive) - webhook_headers = webhook_data.get("headers", {}) - webhook_headers_lower = {k.lower(): v for k, v in webhook_headers.items()} - - for header in self.node_data.headers: - header_name = header.name - value = _get_normalized(webhook_headers, header_name) - if value is None: - value = _get_normalized(webhook_headers_lower, header_name.lower()) - sanitized_name = _to_sanitized(header_name) - outputs[sanitized_name] = value - - # Extract configured query parameters - for param in self.node_data.params: - param_name = param.name - outputs[param_name] = webhook_data.get("query_params", {}).get(param_name) - - # Extract configured body parameters - for body_param in self.node_data.body: - param_name = body_param.name - param_type = body_param.type - - if self.node_data.content_type == ContentType.TEXT: - # For text/plain, the entire body is a single string parameter - outputs[param_name] = str(webhook_data.get("body", {}).get("raw", "")) - continue - elif self.node_data.content_type == ContentType.BINARY: - raw_data: dict[str, Any] = webhook_data.get("body", {}).get("raw", {}) - file_var = self.generate_file_var(param_name, raw_data) - if file_var: - outputs[param_name] = file_var - else: - outputs[param_name] = raw_data - continue - - match param_type: - case SegmentType.FILE: - # Get File object (already processed by webhook controller) - files = webhook_data.get("files", {}) - if files and isinstance(files, dict): - file = files.get(param_name) - if file and isinstance(file, dict): - file_var = self.generate_file_var(param_name, file) - if file_var: - outputs[param_name] = file_var - else: - outputs[param_name] = files - else: - outputs[param_name] = files - else: - outputs[param_name] = files - case _: - # Get regular body parameter - outputs[param_name] = webhook_data.get("body", {}).get(param_name) - - # Include raw webhook data for debugging/advanced use - outputs["_webhook_raw"] = webhook_data - return outputs diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py deleted file mode 100644 index 873cd8607fb109..00000000000000 --- a/api/core/workflow/workflow_entry.py +++ /dev/null @@ -1,605 +0,0 @@ -import logging -import time -from collections.abc import Generator, Mapping, Sequence -from typing import Any, TypedDict -from uuid import uuid4 - -from configs import dify_config -from context import capture_current_context -from core.app.apps.exc import GenerateTaskStoppedError -from core.app.entities.app_invoke_entities import ( - InvokeFrom, - UserFrom, - build_dify_run_context, -) -from core.app.file_access import DatabaseFileAccessController -from core.app.workflow.layers.observability import ObservabilityLayer -from core.credit_usage import CreditUsageAppType -from core.repositories.human_input_repository import HumanInputFormSubmissionRepository -from core.workflow.node_factory import ( - DifyGraphInitContext, - DifyNodeFactory, - is_start_node_type, - resolve_workflow_node_class, -) -from core.workflow.nodes.human_input.boundary import HumanInputFormEventFilter -from core.workflow.system_variables import ( - default_system_variables, - get_node_creation_preload_selectors, - inject_default_system_variable_mappings, - preload_node_creation_variables, -) -from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool -from core.workflow.variable_prefixes import ENVIRONMENT_VARIABLE_NODE_ID -from extensions.otel.runtime import is_instrument_flag_enabled -from factories import file_factory -from graphon.entities.graph_config import NodeConfigDictAdapter -from graphon.errors import WorkflowNodeRunFailedError -from graphon.file import File -from graphon.filters import GraphEventFilterContext, ResponseStreamFilter, filter_graph_events -from graphon.graph import Graph -from graphon.graph_engine import GraphEngine, GraphEngineConfig -from graphon.graph_engine.command_channels import CommandChannel, InMemoryChannel -from graphon.graph_engine.layers import DebugLoggingLayer, ExecutionLimitsLayer, GraphEngineLayer -from graphon.graph_events import GraphEngineEvent, GraphNodeEventBase, GraphRunFailedEvent, is_node_result_event -from graphon.nodes import BuiltinNodeTypes -from graphon.nodes.base.node import Node -from graphon.nodes.container_effects import ContainerAwaitRequest -from graphon.runtime import GraphRuntimeState, ReadOnlyGraphRuntimeStateWrapper, VariablePool -from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_into_variable_pool -from models.workflow import Workflow - -logger = logging.getLogger(__name__) -_file_access_controller = DatabaseFileAccessController() - - -def iter_dify_graph_engine_events( - engine: GraphEngine, - response_stream_filter: ResponseStreamFilter | None = None, -) -> Generator[GraphEngineEvent, None, None]: - """ - Apply Dify's response streaming compatibility filter to GraphEngine events. - - Graphon v0.5.0 emits raw variable stream chunks and requires callers to opt - into the legacy response-ordered stream behavior that Dify exposes to its - workflow runners and tests. - - ``response_stream_filter``, when supplied, must be the same instance a - caller intends to persist on pause (see ``PauseStatePersistenceLayer``) so - the filter's ``paths_map`` reflects everything the engine has actually - streamed for this run. - """ - yield from filter_graph_events( - engine.run(), - context=GraphEventFilterContext.from_engine(engine), - filters=[ - HumanInputFormEventFilter(form_repository=HumanInputFormSubmissionRepository()), - response_stream_filter or ResponseStreamFilter(), - ], - ) - - -class _NodeConfigDict(TypedDict): - id: str - width: int - height: int - type: str - data: dict[str, Any] - - -class _EdgeConfigDict(TypedDict): - source: str - target: str - sourceHandle: str - targetHandle: str - - -class SingleNodeGraphDict(TypedDict): - nodes: list[_NodeConfigDict] - edges: list[_EdgeConfigDict] - - -class WorkflowEntry: - def __init__( - self, - tenant_id: str, - app_id: str, - workflow_id: str, - graph_config: Mapping[str, Any], - graph: Graph, - user_id: str, - user_from: UserFrom, - invoke_from: InvokeFrom, - call_depth: int, - variable_pool: VariablePool, - graph_runtime_state: GraphRuntimeState, - command_channel: CommandChannel | None = None, - response_stream_filter: ResponseStreamFilter | None = None, - ) -> None: - """ - Init workflow entry - :param tenant_id: tenant id - :param app_id: app id - :param workflow_id: workflow id - :param workflow_type: workflow type - :param graph_config: workflow graph config - :param graph: workflow graph - :param user_id: user id - :param user_from: user from - :param invoke_from: invoke from - :param call_depth: call depth - :param variable_pool: variable pool - :param graph_runtime_state: pre-created graph runtime state - :param command_channel: command channel for external control (optional, defaults to InMemoryChannel) - :param response_stream_filter: pre-restored filter for resumed runs (optional, defaults to a fresh - ResponseStreamFilter for runs with no prior pause) - :param thread_pool_id: thread pool id - """ - # check call depth - workflow_call_max_depth = dify_config.WORKFLOW_CALL_MAX_DEPTH - if call_depth > workflow_call_max_depth: - raise ValueError(f"Max workflow call depth {workflow_call_max_depth} reached.") - - # Use provided command channel or default to InMemoryChannel - if command_channel is None: - command_channel = InMemoryChannel() - - self.command_channel = command_channel - self._response_stream_filter = response_stream_filter or ResponseStreamFilter() - execution_context = capture_current_context() - # ponytail: Graphon snapshots omit process-local context; use a public rebind API when Graphon exposes one. - graph_runtime_state._execution_context = execution_context - self.graph_engine = GraphEngine( - workflow_id=workflow_id, - graph=graph, - graph_runtime_state=graph_runtime_state, - command_channel=command_channel, - config=GraphEngineConfig( - min_workers=dify_config.GRAPH_ENGINE_MIN_WORKERS, - max_workers=dify_config.GRAPH_ENGINE_MAX_WORKERS, - scale_up_threshold=dify_config.GRAPH_ENGINE_SCALE_UP_THRESHOLD, - scale_down_idle_time=dify_config.GRAPH_ENGINE_SCALE_DOWN_IDLE_TIME, - ), - ) - - # Add debug logging layer when in debug mode - if dify_config.DEBUG: - logger.info("Debug mode enabled - adding DebugLoggingLayer to GraphEngine") - debug_layer = DebugLoggingLayer( - level="DEBUG", - include_inputs=True, - include_outputs=True, - include_process_data=False, # Process data can be very verbose - logger_name=f"GraphEngine.Debug.{workflow_id[:8]}", # Use workflow ID prefix for unique logger - ) - self.graph_engine.layer(debug_layer) - - # Add execution limits layer - limits_layer = ExecutionLimitsLayer( - max_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME - ) - self.graph_engine.layer(limits_layer) - - # Add observability layer when OTel is enabled - if dify_config.ENABLE_OTEL or is_instrument_flag_enabled(): - self.graph_engine.layer(ObservabilityLayer()) - - def run(self) -> Generator[GraphEngineEvent, None, None]: - graph_engine = self.graph_engine - - try: - # Preserve Dify's response-stream semantics on top of Graphon 0.5.0. - generator = iter_dify_graph_engine_events(graph_engine, self._response_stream_filter) - yield from generator - except GenerateTaskStoppedError: - pass - except Exception as e: - logger.exception("Unknown Error when workflow entry running") - yield GraphRunFailedEvent(error=str(e)) - return - - @classmethod - def single_step_run( - cls, - *, - workflow: Workflow, - node_id: str, - user_id: str, - user_inputs: Mapping[str, Any], - variable_pool: VariablePool, - variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER, - ) -> tuple[Node, Generator[GraphNodeEventBase | ContainerAwaitRequest, None, None]]: - """ - Single step run workflow node - :param workflow: Workflow instance - :param node_id: node id - :param user_id: user id - :param user_inputs: user inputs - :return: - """ - node_config = workflow.get_node_config_by_id(node_id) - node_config_data = node_config["data"] - - # Get node type - node_type = node_config_data.type - if node_type in {BuiltinNodeTypes.LOOP, BuiltinNodeTypes.ITERATION}: - raise ValueError("Loop and Iteration nodes must use their engine-backed debug endpoints") - node_version = str(node_config_data.version) - node_cls = resolve_workflow_node_class( - node_type=node_type, - node_version=node_version, - node_data=node_config_data, - ) - - # init graph context and runtime state - run_context = build_dify_run_context( - tenant_id=workflow.tenant_id, - app_id=workflow.app_id, - user_id=user_id, - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - app_type=CreditUsageAppType.WORKFLOW, - ) - graph_init_context = DifyGraphInitContext( - workflow_id=workflow.id, - graph_config=workflow.graph_dict, - run_context=run_context, - call_depth=0, - ) - graph_runtime_state = GraphRuntimeState( - variable_pool=variable_pool, - start_at=time.perf_counter(), - execution_context=capture_current_context(), - ) - - if is_start_node_type(node_type): - add_node_inputs_to_pool(variable_pool, node_id=node_id, inputs=user_inputs) - - preload_node_creation_variables( - variable_loader=variable_loader, - variable_pool=variable_pool, - selectors=get_node_creation_preload_selectors( - node_type=node_type, - node_data=node_config_data, - ), - ) - - try: - # variable selector to variable mapping - variable_mapping = node_cls.extract_variable_selector_to_variable_mapping( - graph_config=workflow.graph_dict, config=node_config - ) - except NotImplementedError: - variable_mapping = {} - variable_mapping = inject_default_system_variable_mappings( - node_id=node_id, - node_type=node_type, - node_data=node_config_data, - variable_mapping=variable_mapping, - ) - - # Loading missing variable from draft var here, and set it into - # variable_pool. - load_into_variable_pool( - variable_loader=variable_loader, - variable_pool=variable_pool, - variable_mapping=variable_mapping, - user_inputs=user_inputs, - ) - if node_type != BuiltinNodeTypes.DATASOURCE: - cls.mapping_user_inputs_to_variable_pool( - variable_mapping=variable_mapping, - user_inputs=user_inputs, - variable_pool=variable_pool, - tenant_id=workflow.tenant_id, - ) - - # init workflow run state - node_factory = DifyNodeFactory.from_graph_init_context( - graph_init_context=graph_init_context, - graph_runtime_state=graph_runtime_state, - ) - node = node_factory.create_node(node_config) - - try: - generator = cls._run_node_with_layers(node, tenant_id=workflow.tenant_id) - except Exception as e: - logger.exception( - "error while running node, workflow_id=%s, node_id=%s, node_type=%s, node_version=%s", - workflow.id, - node.id, - node.node_type, - node.version(), - ) - raise WorkflowNodeRunFailedError(node=node, err_msg=str(e)) - return node, generator - - @staticmethod - def _create_single_node_graph( - node_id: str, - node_data: dict[str, Any], - node_width: int = 114, - node_height: int = 514, - ) -> SingleNodeGraphDict: - """ - Create a minimal graph structure for testing a single node in isolation. - - :param node_id: ID of the target node - :param node_data: configuration data for the target node - :param node_width: width for UI layout (default: 200) - :param node_height: height for UI layout (default: 100) - :return: graph dictionary with start node and target node - """ - node_config: _NodeConfigDict = { - "id": node_id, - "width": node_width, - "height": node_height, - "type": "custom", - "data": node_data, - } - start_node_config: _NodeConfigDict = { - "id": "start", - "width": node_width, - "height": node_height, - "type": "custom", - "data": { - "type": BuiltinNodeTypes.START, - "title": "Start", - "desc": "Start", - }, - } - return SingleNodeGraphDict( - nodes=[start_node_config, node_config], - edges=[ - { - "source": "start", - "target": node_id, - "sourceHandle": "source", - "targetHandle": "target", - } - ], - ) - - @classmethod - def run_free_node( - cls, node_data: dict[str, Any], node_id: str, tenant_id: str, user_id: str, user_inputs: dict[str, Any] - ) -> tuple[Node, Generator[GraphNodeEventBase | ContainerAwaitRequest, None, None]]: - """ - Run free node - - NOTE: only parameter_extractor/question_classifier are supported - - :param node_data: node data - :param node_id: node id - :param tenant_id: tenant id - :param user_id: user id - :param user_inputs: user inputs - :return: - """ - # Create a minimal graph for single node execution - graph_dict = cls._create_single_node_graph(node_id, node_data) - - node_type = node_data.get("type", "") - if node_type not in {BuiltinNodeTypes.PARAMETER_EXTRACTOR, BuiltinNodeTypes.QUESTION_CLASSIFIER}: - raise ValueError(f"Node type {node_type} not supported") - - node_cls = resolve_workflow_node_class(node_type=node_type, node_version="1") - if not node_cls: - raise ValueError(f"Node class not found for node type {node_type}") - - # init variable pool - variable_pool = VariablePool() - add_variables_to_pool(variable_pool, default_system_variables()) - - # init graph context and runtime state - run_context = build_dify_run_context( - tenant_id=tenant_id, - app_id="", - user_id=user_id, - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - app_type=CreditUsageAppType.WORKFLOW, - ) - graph_init_context = DifyGraphInitContext( - workflow_id="", - graph_config=graph_dict, - run_context=run_context, - call_depth=0, - ) - graph_runtime_state = GraphRuntimeState( - variable_pool=variable_pool, - start_at=time.perf_counter(), - execution_context=capture_current_context(), - ) - - # init workflow run state - node_config = NodeConfigDictAdapter.validate_python({"id": node_id, "data": node_data}) - node_factory = DifyNodeFactory.from_graph_init_context( - graph_init_context=graph_init_context, - graph_runtime_state=graph_runtime_state, - ) - node = node_factory.create_node(node_config) - - try: - # variable selector to variable mapping - try: - variable_mapping = node_cls.extract_variable_selector_to_variable_mapping( - graph_config=graph_dict, config=node_config - ) - except NotImplementedError: - variable_mapping = {} - variable_mapping = inject_default_system_variable_mappings( - node_id=node_id, - node_type=node_type, - node_data=node_data, - variable_mapping=variable_mapping, - ) - - cls.mapping_user_inputs_to_variable_pool( - variable_mapping=variable_mapping, - user_inputs=user_inputs, - variable_pool=variable_pool, - tenant_id=tenant_id, - ) - - generator = cls._run_node_with_layers(node, tenant_id=tenant_id) - - return node, generator - except Exception as e: - logger.exception( - "error while running node, node_id=%s, node_type=%s, node_version=%s", - node.id, - node.node_type, - node.version(), - ) - raise WorkflowNodeRunFailedError(node=node, err_msg=str(e)) - - @staticmethod - def handle_special_values(value: Mapping[str, Any] | None) -> Mapping[str, Any] | None: - # NOTE(QuantumGhost): Avoid using this function in new code. - # Keep values structured as long as possible and only convert to dict - # immediately before serialization (e.g., JSON serialization) to maintain - # data integrity and type information. - result = WorkflowEntry._handle_special_values(value) - return result if isinstance(result, Mapping) or result is None else dict(result) - - @staticmethod - def _handle_special_values(value: Any): - if value is None: - return value - if isinstance(value, dict): - res = {} - for k, v in value.items(): - res[k] = WorkflowEntry._handle_special_values(v) - return res - if isinstance(value, list): - res_list = [] - for item in value: - res_list.append(WorkflowEntry._handle_special_values(item)) - return res_list - if isinstance(value, File): - return value.to_dict() - return value - - @classmethod - def mapping_user_inputs_to_variable_pool( - cls, - *, - variable_mapping: Mapping[str, Sequence[str]], - user_inputs: Mapping[str, Any], - variable_pool: VariablePool, - tenant_id: str, - ): - # NOTE(QuantumGhost): This logic should remain synchronized with - # the implementation of `load_into_variable_pool`, specifically the logic about - # variable existence checking. - - # WARNING(QuantumGhost): The semantics of this method are not clearly defined, - # and multiple parts of the codebase depend on its current behavior. - # Modify with caution. - for node_variable, variable_selector in variable_mapping.items(): - # fetch node id and variable key from node_variable - node_variable_list = node_variable.split(".") - if len(node_variable_list) < 1: - raise ValueError(f"Invalid node variable {node_variable}") - - node_variable_key = ".".join(node_variable_list[1:]) - - if (node_variable_key not in user_inputs and node_variable not in user_inputs) and not variable_pool.get( - variable_selector - ): - raise ValueError(f"Variable key {node_variable} not found in user inputs.") - - # environment variable already exist in variable pool, not from user inputs - if variable_pool.get(variable_selector) and variable_selector[0] == ENVIRONMENT_VARIABLE_NODE_ID: - continue - - # fetch variable node id from variable selector - variable_node_id = variable_selector[0] - variable_key_list = variable_selector[1:] - variable_key_list = list(variable_key_list) - - # get input value - input_value = user_inputs.get(node_variable) - if not input_value: - input_value = user_inputs.get(node_variable_key) - if input_value is None: - continue - - if isinstance(input_value, dict) and "type" in input_value and "transfer_method" in input_value: - input_value = file_factory.build_from_mapping( - mapping=input_value, - tenant_id=tenant_id, - access_controller=_file_access_controller, - ) - if ( - isinstance(input_value, list) - and all(isinstance(item, dict) for item in input_value) - and all("type" in item and "transfer_method" in item for item in input_value) - ): - input_value = file_factory.build_from_mappings( - mappings=input_value, - tenant_id=tenant_id, - access_controller=_file_access_controller, - ) - - # append variable and value to variable pool - if variable_node_id != ENVIRONMENT_VARIABLE_NODE_ID: - # In single run, the input_value is set as the LLM's structured output value within the variable_pool. - if len(variable_key_list) == 2 and variable_key_list[0] == "structured_output": - input_value = {variable_key_list[1]: input_value} - variable_key_list = variable_key_list[0:1] - - # Support for a single node to reference multiple structured_output variables - current_variable = variable_pool.get([variable_node_id] + variable_key_list) - if current_variable and isinstance(current_variable.value, dict): - input_value = current_variable.value | input_value - - variable_pool.add([variable_node_id] + variable_key_list, input_value) - - @staticmethod - def _run_node_with_layers( - node: Node, *, tenant_id: str - ) -> Generator[GraphNodeEventBase | ContainerAwaitRequest, None, None]: - """ - Run a standalone node with the same quota and observability hooks as GraphEngine. - """ - layers: Sequence[GraphEngineLayer] = (ObservabilityLayer(),) - command_channel = InMemoryChannel() - runtime_state = ReadOnlyGraphRuntimeStateWrapper(node.graph_runtime_state) - for layer in layers: - layer.initialize(runtime_state, command_channel) - layer.on_graph_start() - - node.bind_execution_id(str(uuid4())) - - def _gen(): - error: Exception | None = None - result_event: GraphNodeEventBase | None = None - layers_finished = False - - def finish_layers() -> None: - nonlocal layers_finished - if layers_finished: - return - layers_finished = True - for layer in layers: - layer.on_node_run_end(node, error, result_event) - for layer in layers: - layer.on_graph_end(error) - - try: - for layer in layers: - layer.on_node_run_start(node) - for event in node.run(): - if isinstance(event, GraphNodeEventBase) and is_node_result_event(event): - result_event = event - finish_layers() - yield event - except Exception as exc: - error = exc - raise - finally: - finish_layers() - - return _gen() diff --git a/api/services/workflow/execution/adapters/agent_message_transformer.py b/api/services/workflow/execution/adapters/agent_message_transformer.py deleted file mode 100644 index 25f11b6887dead..00000000000000 --- a/api/services/workflow/execution/adapters/agent_message_transformer.py +++ /dev/null @@ -1,341 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Generator, Mapping -from typing import Any, cast - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from core.app.file_access import DatabaseFileAccessController -from core.tools.entities.tool_entities import ToolInvokeMessage -from core.tools.utils.message_transformer import ToolFileMessageTransformer -from core.workflow.nodes.agent.events import AgentLogEvent -from core.workflow.nodes.agent.exceptions import AgentNodeError, AgentVariableTypeError, ToolFileNotFoundError -from core.workflow.nodes.agent.think_tags import ThinkStreamState -from extensions.ext_database import db -from factories import file_factory -from graphon.enums import BuiltinNodeTypes, NodeType, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus -from graphon.file import File, FileTransferMethod, get_file_type_by_mime_type -from graphon.model_runtime.entities.llm_entities import LLMUsage, LLMUsageMetadata -from graphon.model_runtime.utils.encoders import jsonable_encoder -from graphon.node_events import ( - NodeEventBase, - NodeRunResult, - StreamChunkEvent, - StreamCompletedEvent, -) -from graphon.variables.segments import ArrayFileSegment -from models import ToolFile - -_file_access_controller = DatabaseFileAccessController() - - -class AgentMessageTransformer: - def __init__( - self, - provider_icons: Callable[ - [str, str | None], tuple[str | Mapping[str, str] | None, str | Mapping[str, str] | None] - ], - ) -> None: - self._provider_icons = provider_icons - - def transform( - self, - *, - messages: Generator[ToolInvokeMessage, None, None], - tool_info: Mapping[str, Any], - parameters_for_log: dict[str, Any], - user_id: str, - tenant_id: str, - conversation_id: str | None, - node_type: NodeType, - node_id: str, - node_execution_id: str, - ) -> Generator[NodeEventBase, None, None]: - message_stream = ToolFileMessageTransformer.transform_tool_invoke_messages( - messages=messages, - user_id=user_id, - tenant_id=tenant_id, - conversation_id=conversation_id, - ) - - think_state = ThinkStreamState() - files: list[File] = [] - json_list: list[dict | list] = [] - - agent_logs: list[AgentLogEvent] = [] - agent_execution_metadata: Mapping[WorkflowNodeExecutionMetadataKey, Any] = {} - llm_usage = LLMUsage.empty_usage() - variables: dict[str, Any] = {} - - for message in message_stream: - if message.type in { - ToolInvokeMessage.MessageType.IMAGE_LINK, - ToolInvokeMessage.MessageType.BINARY_LINK, - ToolInvokeMessage.MessageType.IMAGE, - }: - assert isinstance(message.message, ToolInvokeMessage.TextMessage) - - url = message.message.text - if message.meta: - transfer_method = message.meta.get("transfer_method", FileTransferMethod.TOOL_FILE) - tool_file_id = message.meta.get("tool_file_id") - else: - transfer_method = FileTransferMethod.TOOL_FILE - tool_file_id = None - if not isinstance(tool_file_id, str) or not tool_file_id: - raise ToolFileNotFoundError("missing tool_file_id metadata") - - with Session(db.engine) as session: - stmt = select(ToolFile).where(ToolFile.id == tool_file_id) - tool_file = session.scalar(stmt) - if tool_file is None: - raise ToolFileNotFoundError(tool_file_id) - - mapping = { - "tool_file_id": tool_file_id, - "type": get_file_type_by_mime_type(tool_file.mimetype), - "transfer_method": transfer_method, - "url": url, - } - file = file_factory.build_from_mapping( - mapping=mapping, - tenant_id=tenant_id, - access_controller=_file_access_controller, - ) - files.append(file) - elif message.type == ToolInvokeMessage.MessageType.BLOB: - assert isinstance(message.message, ToolInvokeMessage.TextMessage) - assert message.meta - - tool_file_id = message.meta.get("tool_file_id") - if not isinstance(tool_file_id, str) or not tool_file_id: - raise ToolFileNotFoundError("missing tool_file_id metadata") - with Session(db.engine) as session: - stmt = select(ToolFile).where(ToolFile.id == tool_file_id) - tool_file = session.scalar(stmt) - if tool_file is None: - raise ToolFileNotFoundError(tool_file_id) - - mapping = { - "tool_file_id": tool_file_id, - "transfer_method": FileTransferMethod.TOOL_FILE, - } - files.append( - file_factory.build_from_mapping( - mapping=mapping, - tenant_id=tenant_id, - access_controller=_file_access_controller, - ) - ) - elif message.type == ToolInvokeMessage.MessageType.TEXT: - assert isinstance(message.message, ToolInvokeMessage.TextMessage) - chunk = think_state.feed_text(message.message.text) - yield StreamChunkEvent( - selector=[node_id, "text"], - chunk=chunk, - is_final=False, - ) - elif message.type == ToolInvokeMessage.MessageType.JSON: - assert isinstance(message.message, ToolInvokeMessage.JsonMessage) - if node_type == BuiltinNodeTypes.AGENT: - if isinstance(message.message.json_object, dict): - msg_metadata: dict[str, Any] = message.message.json_object.pop("execution_metadata", {}) - llm_usage = LLMUsage.from_metadata(cast(LLMUsageMetadata, msg_metadata)) - agent_execution_metadata = { - WorkflowNodeExecutionMetadataKey(key): value - for key, value in msg_metadata.items() - if key in WorkflowNodeExecutionMetadataKey.__members__.values() - } - else: - llm_usage = LLMUsage.empty_usage() - agent_execution_metadata = {} - if message.message.json_object: - json_list.append(message.message.json_object) - elif message.type == ToolInvokeMessage.MessageType.LINK: - assert isinstance(message.message, ToolInvokeMessage.TextMessage) - linked_file = self._file_from_link_message(message=message, tenant_id=tenant_id) - if linked_file is not None: - files.append(linked_file) - yield from self._close_open_think(think_state=think_state, node_id=node_id) - stream_text = f"{'File' if linked_file is not None else 'Link'}: {message.message.text}\n" - think_state.feed_text(stream_text) - yield StreamChunkEvent( - selector=[node_id, "text"], - chunk=stream_text, - is_final=False, - ) - elif message.type == ToolInvokeMessage.MessageType.VARIABLE: - assert isinstance(message.message, ToolInvokeMessage.VariableMessage) - variable_name = message.message.variable_name - variable_value = message.message.variable_value - if message.message.stream: - if not isinstance(variable_value, str): - raise AgentVariableTypeError( - "When 'stream' is True, 'variable_value' must be a string.", - variable_name=variable_name, - expected_type="str", - actual_type=type(variable_value).__name__, - ) - if variable_name not in variables: - variables[variable_name] = "" - variables[variable_name] += variable_value - - yield StreamChunkEvent( - selector=[node_id, variable_name], - chunk=variable_value, - is_final=False, - ) - else: - variables[variable_name] = variable_value - elif message.type == ToolInvokeMessage.MessageType.FILE: - assert message.meta is not None - assert isinstance(message.meta, dict) - if "file" not in message.meta: - raise AgentNodeError("File message is missing 'file' key in meta") - - if not isinstance(message.meta["file"], File): - raise AgentNodeError(f"Expected File object but got {type(message.meta['file']).__name__}") - files.append(message.meta["file"]) - elif message.type == ToolInvokeMessage.MessageType.LOG: - assert isinstance(message.message, ToolInvokeMessage.LogMessage) - if message.message.metadata: - icon = tool_info.get("icon", "") - dict_metadata = dict(message.message.metadata) - if dict_metadata.get("provider"): - icon, icon_dark = self._provider_icons(dict_metadata["provider"], icon) - dict_metadata["icon"] = icon - dict_metadata["icon_dark"] = icon_dark - message.message.metadata = dict_metadata - agent_log = AgentLogEvent( - message_id=message.message.id, - node_execution_id=node_execution_id, - parent_id=message.message.parent_id, - error=message.message.error, - status=message.message.status.value, - data=message.message.data, - label=message.message.label, - metadata=message.message.metadata, - node_id=node_id, - ) - - for log in agent_logs: - if log.message_id == agent_log.message_id: - log.data = agent_log.data - log.status = agent_log.status - log.error = agent_log.error - log.label = agent_log.label - log.metadata = agent_log.metadata - break - else: - agent_logs.append(agent_log) - - yield from self._close_open_think(think_state=think_state, node_id=node_id) - - yield agent_log - - json_output: list[dict[str, Any] | list[Any]] = [] - if agent_logs: - for log in agent_logs: - json_output.append( - { - "id": log.message_id, - "parent_id": log.parent_id, - "error": log.error, - "status": log.status, - "data": log.data, - "label": log.label, - "metadata": log.metadata, - "node_id": log.node_id, - } - ) - if json_list: - json_output.extend(json_list) - else: - json_output.append({"data": []}) - - yield from self._close_open_think(think_state=think_state, node_id=node_id) - - yield StreamChunkEvent( - selector=[node_id, "text"], - chunk="", - is_final=True, - ) - - for var_name in variables: - yield StreamChunkEvent( - selector=[node_id, var_name], - chunk="", - is_final=True, - ) - - yield StreamCompletedEvent( - node_run_result=NodeRunResult( - status=WorkflowNodeExecutionStatus.SUCCEEDED, - outputs={ - "text": think_state.text, - "usage": jsonable_encoder(llm_usage), - "files": ArrayFileSegment(value=files), - "json": json_output, - **variables, - }, - metadata={ - **agent_execution_metadata, - WorkflowNodeExecutionMetadataKey.TOOL_INFO: tool_info, - WorkflowNodeExecutionMetadataKey.AGENT_LOG: agent_logs, - }, - inputs=parameters_for_log, - llm_usage=llm_usage, - ) - ) - - @staticmethod - def _close_open_think(*, think_state: ThinkStreamState, node_id: str) -> Generator[StreamChunkEvent, None, None]: - closed = think_state.close_if_open() - if closed is None: - return - yield StreamChunkEvent( - selector=[node_id, "text"], - chunk=closed, - is_final=False, - ) - - @staticmethod - def _file_from_link_message(*, message: ToolInvokeMessage, tenant_id: str) -> File | None: - if not isinstance(message.message, ToolInvokeMessage.TextMessage): - return None - meta = message.meta - if not isinstance(meta, Mapping): - return None - - file_value = meta.get("file") - if isinstance(file_value, File): - return file_value - - tool_file_id = meta.get("tool_file_id") - if isinstance(tool_file_id, str) and tool_file_id: - with Session(db.engine) as session: - tool_file = session.scalar( - select(ToolFile).where(ToolFile.id == tool_file_id, ToolFile.tenant_id == tenant_id) - ) - if tool_file is None: - raise ToolFileNotFoundError(tool_file_id) - - return file_factory.build_from_mapping( - mapping={ - "tool_file_id": tool_file_id, - "type": get_file_type_by_mime_type(tool_file.mimetype), - "transfer_method": meta.get("transfer_method", FileTransferMethod.TOOL_FILE), - "url": message.message.text, - }, - tenant_id=tenant_id, - access_controller=_file_access_controller, - ) - - if isinstance(file_value, Mapping): - return file_factory.build_from_mapping( - mapping=dict(file_value), - tenant_id=tenant_id, - access_controller=_file_access_controller, - ) - return None diff --git a/api/services/workflow/execution/adapters/node_factory.py b/api/services/workflow/execution/adapters/node_factory.py index 0a6e2e31928326..6a61f3d76a5df4 100644 --- a/api/services/workflow/execution/adapters/node_factory.py +++ b/api/services/workflow/execution/adapters/node_factory.py @@ -119,24 +119,7 @@ def _import_node_package(package_name: str, *, excluded_modules: frozenset[str] 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", - excluded_modules=frozenset( - { - "core.workflow.nodes.agent.agent_node", - "core.workflow.nodes.agent.runtime_support", - "core.workflow.nodes.agent_v2.agent_node", - "core.workflow.nodes.agent_v2.binding_resolver", - "core.workflow.nodes.agent_v2.dify_tools_builder", - "core.workflow.nodes.agent_v2.runtime_request_builder", - "core.workflow.nodes.agent_v2.validators", - "core.workflow.nodes.human_input.boundary", - "core.workflow.nodes.human_input.callback", - "core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node", - "core.workflow.nodes.trigger_webhook.node", - } - ), - ) + _import_node_package("core.workflow.nodes") for module_name in ( "services.workflow.execution.adapters.agent_node", "services.workflow.execution.adapters.knowledge_retrieval", From 3616f9b7cd9920077c5ec8249858251d83d4751c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:50:12 +0000 Subject: [PATCH 2/7] [autofix.ci] apply automated fixes --- api/openapi/markdown/console-openapi.md | 94 +++++++------------ api/openapi/markdown/service-openapi.md | 2 +- api/openapi/markdown/web-openapi.md | 2 +- .../agent/legacy_workflow_publish_service.py | 2 +- .../generated/api/console/apps/orpc.gen.ts | 94 ++----------------- .../generated/api/console/apps/types.gen.ts | 12 +-- .../generated/api/console/apps/zod.gen.ts | 8 +- .../generated/api/console/rag/orpc.gen.ts | 30 +++--- .../generated/api/console/rag/types.gen.ts | 44 ++++++--- .../generated/api/console/rag/zod.gen.ts | 45 +++++++-- .../api/console/snippets/orpc.gen.ts | 4 +- 11 files changed, 137 insertions(+), 200 deletions(-) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 2f4d95bc57d21f..759c421256b5af 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -1730,8 +1730,6 @@ Get advanced chat workflow run list | 200 | Workflow runs count retrieved successfully | **application/json**: [WorkflowRunCountResponse](#workflowruncountresponse)
| ### [POST] /apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/form/preview -**Preview human input form content and placeholders** - Get human input form preview for advanced chat workflow #### Parameters @@ -1754,8 +1752,6 @@ Get human input form preview for advanced chat workflow | 200 | Human input form preview | **application/json**: [HumanInputFormPreviewResponse](#humaninputformpreviewresponse)
| ### [POST] /apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/form/run -**Submit human input form preview** - Submit human input form preview for advanced chat workflow #### Parameters @@ -1778,8 +1774,6 @@ Submit human input form preview for advanced chat workflow | 200 | Human input form submission result | **application/json**: [HumanInputFormSubmitResponse](#humaninputformsubmitresponse)
| ### [POST] /apps/{app_id}/advanced-chat/workflows/draft/iteration/nodes/{node_id}/run -**Run draft workflow iteration node** - Run draft workflow iteration node for advanced chat #### Parameters @@ -1804,8 +1798,6 @@ Run draft workflow iteration node for advanced chat | 404 | Node not found | | ### [POST] /apps/{app_id}/advanced-chat/workflows/draft/loop/nodes/{node_id}/run -**Run draft workflow loop node** - Run draft workflow loop node for advanced chat #### Parameters @@ -1830,8 +1822,6 @@ Run draft workflow loop node for advanced chat | 404 | Node not found | | ### [POST] /apps/{app_id}/advanced-chat/workflows/draft/run -**Run draft workflow** - Run draft workflow for advanced chat application #### Parameters @@ -2650,11 +2640,7 @@ Get conversation variables for an application | 200 | Conversation variables retrieved successfully | **application/json**: [PaginatedConversationVariableResponse](#paginatedconversationvariableresponse)
| ### [POST] /apps/{app_id}/convert-to-workflow -**Convert basic mode of chatbot app to workflow mode** - Convert application to workflow mode -Convert expert mode of chatbot app to workflow mode -Convert Completion App to Workflow App #### Parameters @@ -3443,8 +3429,6 @@ Get workflow application execution logs | 200 | Workflow runs count retrieved successfully | **application/json**: [WorkflowRunCountResponse](#workflowruncountresponse)
| ### [POST] /apps/{app_id}/workflow-runs/tasks/{task_id}/stop -**Stop workflow task** - Stop running workflow task #### Parameters @@ -3809,8 +3793,6 @@ Get workflow daily token cost statistics | 200 | Daily token cost statistics retrieved successfully | **application/json**: [WorkflowDailyTokenCostStatisticResponse](#workflowdailytokencoststatisticresponse)
| ### [GET] /apps/{app_id}/workflows -**Get published workflows** - Get all published workflows for an application #### Parameters @@ -3830,8 +3812,6 @@ Get all published workflows for an application | 200 | Published workflows retrieved successfully | **application/json**: [WorkflowPaginationResponse](#workflowpaginationresponse)
| ### [GET] /apps/{app_id}/workflows/default-workflow-block-configs -**Get default block config** - Get default block configurations for workflow #### Parameters @@ -3847,8 +3827,6 @@ Get default block configurations for workflow | 200 | Default block configurations retrieved successfully | **application/json**: [DefaultBlockConfigsResponse](#defaultblockconfigsresponse)
| ### [GET] /apps/{app_id}/workflows/default-workflow-block-configs/{block_type} -**Get default block config** - Get default block configuration by type #### Parameters @@ -3867,8 +3845,6 @@ Get default block configuration by type | 404 | Block type not found | | ### [GET] /apps/{app_id}/workflows/draft -**Get draft workflow** - Get draft workflow for an application #### Parameters @@ -3885,8 +3861,6 @@ Get draft workflow for an application | 404 | Draft workflow not found | | ### [POST] /apps/{app_id}/workflows/draft -**Sync draft workflow** - Sync draft workflow configuration #### Parameters @@ -3947,7 +3921,7 @@ Update conversation variables for workflow draft | 200 | Conversation variables updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [GET] /apps/{app_id}/workflows/draft/environment-variables -**Get draft workflow** +**Get environment variables** Get environment variables for workflow @@ -4007,8 +3981,6 @@ Update draft workflow features | 200 | Workflow features updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| ### [POST] /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/delivery-test -**Test human input delivery** - Test human input delivery for workflow #### Parameters @@ -4031,8 +4003,6 @@ Test human input delivery for workflow | 200 | Human input delivery test result | **application/json**: [EmptyObjectResponse](#emptyobjectresponse)
| ### [POST] /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/preview -**Preview human input form content and placeholders** - Get human input form preview for workflow #### Parameters @@ -4055,8 +4025,6 @@ Get human input form preview for workflow | 200 | Human input form preview | **application/json**: [HumanInputFormPreviewResponse](#humaninputformpreviewresponse)
| ### [POST] /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/run -**Submit human input form preview** - Submit human input form preview for workflow #### Parameters @@ -4079,7 +4047,7 @@ Submit human input form preview for workflow | 200 | Human input form submission result | **application/json**: [HumanInputFormSubmitResponse](#humaninputformsubmitresponse)
| ### [POST] /apps/{app_id}/workflows/draft/iteration/nodes/{node_id}/run -**Run draft workflow iteration node** +Run draft workflow iteration node #### Parameters @@ -4103,7 +4071,7 @@ Submit human input form preview for workflow | 404 | Node not found | | ### [POST] /apps/{app_id}/workflows/draft/loop/nodes/{node_id}/run -**Run draft workflow loop node** +Run draft workflow loop node #### Parameters @@ -4274,7 +4242,7 @@ Get last run result for draft workflow node | 404 | Node last run not found | | ### [POST] /apps/{app_id}/workflows/draft/nodes/{node_id}/run -**Run draft workflow node** +Run draft workflow node #### Parameters @@ -4298,7 +4266,7 @@ Get last run result for draft workflow node | 404 | Node not found | | ### [POST] /apps/{app_id}/workflows/draft/nodes/{node_id}/trigger/run -**Poll for trigger events and execute single node when event arrives** +Poll for trigger events and execute single node when event arrives #### Parameters @@ -4348,7 +4316,7 @@ Get variables for a specific node | 200 | Node variables retrieved successfully | **application/json**: [WorkflowDraftVariableListResponse](#workflowdraftvariablelistresponse)
| ### [POST] /apps/{app_id}/workflows/draft/run -**Run draft workflow** +Run draft workflow #### Parameters @@ -4456,7 +4424,7 @@ Get system variables for workflow | 200 | System variables retrieved successfully | **application/json**: [WorkflowDraftVariableListResponse](#workflowdraftvariablelistresponse)
| ### [POST] /apps/{app_id}/workflows/draft/trigger/run -**Poll for trigger events and execute full workflow when event arrives** +Poll for trigger events and execute full workflow when event arrives #### Parameters @@ -4479,7 +4447,7 @@ Get system variables for workflow | 500 | Internal server error | | ### [POST] /apps/{app_id}/workflows/draft/trigger/run-all -**Full workflow debug when the start node is a trigger** +Full workflow debug when the start node is a trigger #### Parameters @@ -4517,7 +4485,7 @@ Delete all draft workflow variables | 204 | Workflow variables deleted successfully | ### [GET] /apps/{app_id}/workflows/draft/variables -**Get draft workflow** +**List draft workflow variables without loading their values** Get draft workflow variables @@ -4611,8 +4579,6 @@ Reset a workflow variable to its default value | 404 | Variable not found | | ### [GET] /apps/{app_id}/workflows/publish -**Get published workflow** - Get published workflow for an application #### Parameters @@ -4628,8 +4594,6 @@ Get published workflow for an application | 200 | Published workflow retrieved successfully, or null if not found | **application/json**: [WorkflowResponse](#workflowresponse)
| ### [POST] /apps/{app_id}/workflows/publish -**Publish workflow** - #### Parameters | Name | Located in | Description | Required | Schema | @@ -4736,8 +4700,6 @@ Full value for one declared output of a published run. | 200 | Success | **application/json**: [WebhookTriggerResponse](#webhooktriggerresponse)
| ### [DELETE] /apps/{app_id}/workflows/{workflow_id} -**Delete workflow** - #### Parameters | Name | Located in | Description | Required | Schema | @@ -4749,11 +4711,9 @@ Full value for one declared output of a published run. | Code | Description | | ---- | ----------- | -| 200 | Success | +| 204 | Workflow deleted successfully | ### [PATCH] /apps/{app_id}/workflows/{workflow_id} -**Update workflow attributes** - Update workflow by ID #### Parameters @@ -7827,11 +7787,17 @@ Update account-level Step-by-step Tour state | ---- | ---------- | ----------- | -------- | ------ | | pipeline_id | path | | Yes | string (uuid) | +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DraftWorkflowSyncPayload](#draftworkflowsyncpayload)
| + #### Responses -| Code | Description | -| ---- | ----------- | -| 200 | Success | +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RagPipelineWorkflowSyncResponse](#ragpipelineworkflowsyncresponse)
| ### [POST] /rag/pipelines/{pipeline_id}/workflows/draft/datasource/nodes/{node_id}/run **Run rag pipeline datasource** @@ -7877,7 +7843,7 @@ Update account-level Step-by-step Tour state | 200 | Datasource variables set successfully | **application/json**: [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse)
| ### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/environment-variables -**Get draft workflow** +**Get environment variables** #### Parameters @@ -8079,7 +8045,7 @@ Update account-level Step-by-step Tour state | 204 | Workflow variables deleted successfully | ### [GET] /rag/pipelines/{pipeline_id}/workflows/draft/variables -**Get draft workflow** +**List draft pipeline variables without loading their values** #### Parameters @@ -8131,6 +8097,12 @@ Update account-level Step-by-step Tour state | pipeline_id | path | | Yes | string (uuid) | | variable_id | path | | Yes | string (uuid) | +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowDraftVariablePatchPayload](#workflowdraftvariablepatchpayload)
| + #### Responses | Code | Description | Schema | @@ -8168,7 +8140,7 @@ Update account-level Step-by-step Tour state | 200 | Published workflow retrieved successfully, or null if not exist | **application/json**: [WorkflowResponse](#workflowresponse)
| ### [POST] /rag/pipelines/{pipeline_id}/workflows/publish -**Publish workflow** +**Publish a prepared Pipeline and its dataset settings atomically** #### Parameters @@ -8280,8 +8252,6 @@ Update account-level Step-by-step Tour state | 200 | Success | **application/json**: [RagPipelineOpaqueResponse](#ragpipelineopaqueresponse)
| ### [DELETE] /rag/pipelines/{pipeline_id}/workflows/{workflow_id} -**Delete a published workflow version that is not currently active on the pipeline** - #### Parameters | Name | Located in | Description | Required | Schema | @@ -9074,7 +9044,9 @@ Reset a draft workflow variable to its default value (snippet scope) | 400 | No draft workflow found | | ### [DELETE] /snippets/{snippet_id}/workflows/{workflow_id} -**Delete a published snippet workflow version** +**Delete a version and retire its unowned Agents through the shared use case** + +Delete a published snippet workflow version #### Parameters @@ -15533,10 +15505,10 @@ Legacy Chat App model config used only for follow-up question generation. | status | string | | Yes | | time_cost | number
integer | | Yes | | tool_icon | | | No | -| tool_input | object | | Yes | +| tool_input | | | Yes | | tool_label | string | | Yes | | tool_name | string | | Yes | -| tool_output | object | | Yes | +| tool_output | | | Yes | | tool_parameters | object | | Yes | #### AgentUserSatisfactionRateStatisticResponse diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index a30ee05de6fa0a..86f95c03781c20 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -1018,7 +1018,7 @@ Execute the full knowledge pipeline for a knowledge base. Published runs are que | 200 | Pipeline execution result. Published runs return a JSON object containing `batch`, `dataset`, and `documents`. Draft runs return `text/event-stream` for streaming mode or a workflow result JSON object for blocking mode. | **application/json**: [PipelineRunJsonResponse](#pipelinerunjsonresponse)
**text/event-stream**: string
| | 400 | Bad request - invalid payload or pipeline is not configured | | | 401 | Unauthorized - invalid API token | | -| 403 | `forbidden` : Forbidden. | | +| 403 | `forbidden` : AccessDeniedError. | | | 404 | `not_found` : Dataset not found. | | | 500 | `pipeline_run_error` : Pipeline execution failed. | | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 1f6d7a8e723090..cdd295149db3c4 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -361,7 +361,7 @@ GET /api/form/human_input/ | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Form retrieved successfully | **application/json**: [HumanInputFormDefinitionResponse](#humaninputformdefinitionresponse)
| -| 403 | Forbidden | | +| 403 | AccessDeniedError | | | 404 | Form not found | | | 412 | Form already submitted or expired | | | 429 | Too many requests | | diff --git a/api/services/agent/legacy_workflow_publish_service.py b/api/services/agent/legacy_workflow_publish_service.py index 358b49e80a18dc..e06e9de083f855 100644 --- a/api/services/agent/legacy_workflow_publish_service.py +++ b/api/services/agent/legacy_workflow_publish_service.py @@ -4,12 +4,12 @@ from collections.abc import Mapping from typing import Any, cast +from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError, WorkflowAgentNodeValidator from pydantic import ValidationError from sqlalchemy import select from sqlalchemy.orm import Session from core.agent.publish_visibility import workflow_callable_active_snapshot_filter -from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError, WorkflowAgentNodeValidator from models.agent import ( Agent, AgentConfigSnapshot, diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 8df754ae8f6ee2..8197443bd438b7 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -632,8 +632,6 @@ export const workflowRuns = { } /** - * Preview human input form content and placeholders - * * Get human input form preview for advanced chat workflow */ export const post4 = oc @@ -643,7 +641,6 @@ export const post4 = oc method: 'POST', operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreview', path: '/apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/form/preview', - summary: 'Preview human input form content and placeholders', tags: ['console'], }) .input( @@ -659,8 +656,6 @@ export const preview = { } /** - * Submit human input form preview - * * Submit human input form preview for advanced chat workflow */ export const post5 = oc @@ -670,7 +665,6 @@ export const post5 = oc method: 'POST', operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRun', path: '/apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/form/run', - summary: 'Submit human input form preview', tags: ['console'], }) .input( @@ -703,8 +697,6 @@ export const humanInput = { } /** - * Run draft workflow iteration node - * * Run draft workflow iteration node for advanced chat */ export const post6 = oc @@ -714,7 +706,6 @@ export const post6 = oc method: 'POST', operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRun', path: '/apps/{app_id}/advanced-chat/workflows/draft/iteration/nodes/{node_id}/run', - summary: 'Run draft workflow iteration node', tags: ['console'], }) .input( @@ -742,8 +733,6 @@ export const iteration = { } /** - * Run draft workflow loop node - * * Run draft workflow loop node for advanced chat */ export const post7 = oc @@ -753,7 +742,6 @@ export const post7 = oc method: 'POST', operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRun', path: '/apps/{app_id}/advanced-chat/workflows/draft/loop/nodes/{node_id}/run', - summary: 'Run draft workflow loop node', tags: ['console'], }) .input( @@ -781,8 +769,6 @@ export const loop = { } /** - * Run draft workflow - * * Run draft workflow for advanced chat application */ export const post8 = oc @@ -792,7 +778,6 @@ export const post8 = oc method: 'POST', operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftRun', path: '/apps/{app_id}/advanced-chat/workflows/draft/run', - summary: 'Run draft workflow', tags: ['console'], }) .input( @@ -1750,21 +1735,15 @@ export const conversationVariables = { } /** - * Convert basic mode of chatbot app to workflow mode - * * Convert application to workflow mode - * Convert expert mode of chatbot app to workflow mode - * Convert Completion App to Workflow App */ export const post21 = oc .route({ - description: - 'Convert application to workflow mode\nConvert expert mode of chatbot app to workflow mode\nConvert Completion App to Workflow App', + description: 'Convert application to workflow mode', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdConvertToWorkflow', path: '/apps/{app_id}/convert-to-workflow', - summary: 'Convert basic mode of chatbot app to workflow mode', tags: ['console'], }) .input( @@ -2600,8 +2579,6 @@ export const count3 = { } /** - * Stop workflow task - * * Stop running workflow task */ export const post38 = oc @@ -2611,7 +2588,6 @@ export const post38 = oc method: 'POST', operationId: 'postAppsByAppIdWorkflowRunsTasksByTaskIdStop', path: '/apps/{app_id}/workflow-runs/tasks/{task_id}/stop', - summary: 'Stop workflow task', tags: ['console'], }) .input(z.object({ params: zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopPath })) @@ -3142,8 +3118,6 @@ export const workflow = { } /** - * Get default block config - * * Get default block configuration by type */ export const get61 = oc @@ -3153,7 +3127,6 @@ export const get61 = oc method: 'GET', operationId: 'getAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockType', path: '/apps/{app_id}/workflows/default-workflow-block-configs/{block_type}', - summary: 'Get default block config', tags: ['console'], }) .input( @@ -3169,8 +3142,6 @@ export const byBlockType = { } /** - * Get default block config - * * Get default block configurations for workflow */ export const get62 = oc @@ -3180,7 +3151,6 @@ export const get62 = oc method: 'GET', operationId: 'getAppsByAppIdWorkflowsDefaultWorkflowBlockConfigs', path: '/apps/{app_id}/workflows/default-workflow-block-configs', - summary: 'Get default block config', tags: ['console'], }) .input(z.object({ params: zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsPath })) @@ -3232,7 +3202,7 @@ export const conversationVariables2 = { } /** - * Get draft workflow + * Get environment variables * * Get environment variables for workflow */ @@ -3243,7 +3213,7 @@ export const get64 = oc method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftEnvironmentVariables', path: '/apps/{app_id}/workflows/draft/environment-variables', - summary: 'Get draft workflow', + summary: 'Get environment variables', tags: ['console'], }) .input(z.object({ params: zGetAppsByAppIdWorkflowsDraftEnvironmentVariablesPath })) @@ -3299,8 +3269,6 @@ export const features = { } /** - * Test human input delivery - * * Test human input delivery for workflow */ export const post46 = oc @@ -3310,7 +3278,6 @@ export const post46 = oc method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTest', path: '/apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/delivery-test', - summary: 'Test human input delivery', tags: ['console'], }) .input( @@ -3326,8 +3293,6 @@ export const deliveryTest = { } /** - * Preview human input form content and placeholders - * * Get human input form preview for workflow */ export const post47 = oc @@ -3337,7 +3302,6 @@ export const post47 = oc method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreview', path: '/apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/preview', - summary: 'Preview human input form content and placeholders', tags: ['console'], }) .input( @@ -3353,8 +3317,6 @@ export const preview4 = { } /** - * Submit human input form preview - * * Submit human input form preview for workflow */ export const post48 = oc @@ -3364,7 +3326,6 @@ export const post48 = oc method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRun', path: '/apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/run', - summary: 'Submit human input form preview', tags: ['console'], }) .input( @@ -3398,8 +3359,6 @@ export const humanInput2 = { } /** - * Run draft workflow iteration node - * * Run draft workflow iteration node */ export const post49 = oc @@ -3409,7 +3368,6 @@ export const post49 = oc method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRun', path: '/apps/{app_id}/workflows/draft/iteration/nodes/{node_id}/run', - summary: 'Run draft workflow iteration node', tags: ['console'], }) .input( @@ -3437,8 +3395,6 @@ export const iteration2 = { } /** - * Run draft workflow loop node - * * Run draft workflow loop node */ export const post50 = oc @@ -3448,7 +3404,6 @@ export const post50 = oc method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRun', path: '/apps/{app_id}/workflows/draft/loop/nodes/{node_id}/run', - summary: 'Run draft workflow loop node', tags: ['console'], }) .input( @@ -3634,8 +3589,6 @@ export const lastRun = { } /** - * Run draft workflow node - * * Run draft workflow node */ export const post55 = oc @@ -3645,7 +3598,6 @@ export const post55 = oc method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdRun', path: '/apps/{app_id}/workflows/draft/nodes/{node_id}/run', - summary: 'Run draft workflow node', tags: ['console'], }) .input( @@ -3661,8 +3613,6 @@ export const run8 = { } /** - * Poll for trigger events and execute single node when event arrives - * * Poll for trigger events and execute single node when event arrives */ export const post56 = oc @@ -3672,7 +3622,6 @@ export const post56 = oc method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRun', path: '/apps/{app_id}/workflows/draft/nodes/{node_id}/trigger/run', - summary: 'Poll for trigger events and execute single node when event arrives', tags: ['console'], }) .input(z.object({ params: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunPath })) @@ -3735,8 +3684,6 @@ export const nodes7 = { } /** - * Run draft workflow - * * Run draft workflow */ export const post57 = oc @@ -3746,7 +3693,6 @@ export const post57 = oc method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftRun', path: '/apps/{app_id}/workflows/draft/run', - summary: 'Run draft workflow', tags: ['console'], }) .input( @@ -3876,8 +3822,6 @@ export const systemVariables = { } /** - * Poll for trigger events and execute full workflow when event arrives - * * Poll for trigger events and execute full workflow when event arrives */ export const post58 = oc @@ -3887,7 +3831,6 @@ export const post58 = oc method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftTriggerRun', path: '/apps/{app_id}/workflows/draft/trigger/run', - summary: 'Poll for trigger events and execute full workflow when event arrives', tags: ['console'], }) .input( @@ -3903,8 +3846,6 @@ export const run11 = { } /** - * Full workflow debug when the start node is a trigger - * * Full workflow debug when the start node is a trigger */ export const post59 = oc @@ -3914,7 +3855,6 @@ export const post59 = oc method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftTriggerRunAll', path: '/apps/{app_id}/workflows/draft/trigger/run-all', - summary: 'Full workflow debug when the start node is a trigger', tags: ['console'], }) .input( @@ -4028,7 +3968,7 @@ export const delete13 = oc .output(zDeleteAppsByAppIdWorkflowsDraftVariablesResponse) /** - * Get draft workflow + * List draft workflow variables without loading their values * * Get draft workflow variables */ @@ -4039,7 +3979,7 @@ export const get75 = oc method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftVariables', path: '/apps/{app_id}/workflows/draft/variables', - summary: 'Get draft workflow', + summary: 'List draft workflow variables without loading their values', tags: ['console'], }) .input( @@ -4057,8 +3997,6 @@ export const variables2 = { } /** - * Get draft workflow - * * Get draft workflow for an application */ export const get76 = oc @@ -4068,15 +4006,12 @@ export const get76 = oc method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraft', path: '/apps/{app_id}/workflows/draft', - summary: 'Get draft workflow', tags: ['console'], }) .input(z.object({ params: zGetAppsByAppIdWorkflowsDraftPath })) .output(zGetAppsByAppIdWorkflowsDraftResponse) /** - * Sync draft workflow - * * Sync draft workflow configuration */ export const post60 = oc @@ -4086,7 +4021,6 @@ export const post60 = oc method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraft', path: '/apps/{app_id}/workflows/draft', - summary: 'Sync draft workflow', tags: ['console'], }) .input( @@ -4115,8 +4049,6 @@ export const draft2 = { } /** - * Get published workflow - * * Get published workflow for an application */ export const get77 = oc @@ -4126,22 +4058,17 @@ export const get77 = oc method: 'GET', operationId: 'getAppsByAppIdWorkflowsPublish', path: '/apps/{app_id}/workflows/publish', - summary: 'Get published workflow', tags: ['console'], }) .input(z.object({ params: zGetAppsByAppIdWorkflowsPublishPath })) .output(zGetAppsByAppIdWorkflowsPublishResponse) -/** - * Publish workflow - */ export const post61 = oc .route({ inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsPublish', path: '/apps/{app_id}/workflows/publish', - summary: 'Publish workflow', tags: ['console'], }) .input( @@ -4307,24 +4234,19 @@ export const restore = { post: post62, } -/** - * Delete workflow - */ export const delete14 = oc .route({ inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteAppsByAppIdWorkflowsByWorkflowId', path: '/apps/{app_id}/workflows/{workflow_id}', - summary: 'Delete workflow', + successStatus: 204, tags: ['console'], }) .input(z.object({ params: zDeleteAppsByAppIdWorkflowsByWorkflowIdPath })) .output(zDeleteAppsByAppIdWorkflowsByWorkflowIdResponse) /** - * Update workflow attributes - * * Update workflow by ID */ export const patch3 = oc @@ -4334,7 +4256,6 @@ export const patch3 = oc method: 'PATCH', operationId: 'patchAppsByAppIdWorkflowsByWorkflowId', path: '/apps/{app_id}/workflows/{workflow_id}', - summary: 'Update workflow attributes', tags: ['console'], }) .input( @@ -4352,8 +4273,6 @@ export const byWorkflowId = { } /** - * Get published workflows - * * Get all published workflows for an application */ export const get83 = oc @@ -4363,7 +4282,6 @@ export const get83 = oc method: 'GET', operationId: 'getAppsByAppIdWorkflows', path: '/apps/{app_id}/workflows', - summary: 'Get published workflows', tags: ['console'], }) .input( diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 2d4fbb1b6c22ad..bec2b43430dfe0 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -2496,14 +2496,10 @@ export type AgentToolCallResponse = { status: string time_cost: number | number tool_icon?: unknown - tool_input: { - [key: string]: unknown - } + tool_input: unknown tool_label: string tool_name: string - tool_output: { - [key: string]: unknown - } + tool_output: unknown tool_parameters: { [key: string]: unknown } @@ -7596,9 +7592,7 @@ export type DeleteAppsByAppIdWorkflowsByWorkflowIdData = { } export type DeleteAppsByAppIdWorkflowsByWorkflowIdResponses = { - 200: { - [key: string]: unknown - } + 204: void } export type DeleteAppsByAppIdWorkflowsByWorkflowIdResponse = diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index eb7445c1fc7415..2570468168d888 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -2195,10 +2195,10 @@ export const zAgentToolCallResponse = z.object({ status: z.string(), time_cost: z.union([z.number(), z.int()]), tool_icon: z.unknown().optional(), - tool_input: z.record(z.string(), z.unknown()), + tool_input: z.unknown(), tool_label: z.string(), tool_name: z.string(), - tool_output: z.record(z.string(), z.unknown()), + tool_output: z.unknown(), tool_parameters: z.record(z.string(), z.unknown()), }) @@ -7508,9 +7508,9 @@ export const zDeleteAppsByAppIdWorkflowsByWorkflowIdPath = z.object({ }) /** - * Success + * Workflow deleted successfully */ -export const zDeleteAppsByAppIdWorkflowsByWorkflowIdResponse = z.record(z.string(), z.unknown()) +export const zDeleteAppsByAppIdWorkflowsByWorkflowIdResponse = z.void() export const zPatchAppsByAppIdWorkflowsByWorkflowIdBody = zWorkflowUpdatePayload diff --git a/packages/contracts/generated/api/console/rag/orpc.gen.ts b/packages/contracts/generated/api/console/rag/orpc.gen.ts index d2a27ca561d997..223139073f73ca 100644 --- a/packages/contracts/generated/api/console/rag/orpc.gen.ts +++ b/packages/contracts/generated/api/console/rag/orpc.gen.ts @@ -75,6 +75,7 @@ import { zPatchRagPipelineCustomizedTemplatesByTemplateIdResponse, zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdPath, zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponse, + zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdBody, zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath, zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse, zPostRagPipelineCustomizedTemplatesByTemplateIdPath, @@ -89,6 +90,7 @@ import { zPostRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStopResponse, zPostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestorePath, zPostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestoreResponse, + zPostRagPipelinesByPipelineIdWorkflowsDraftBody, zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunBody, zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunPath, zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunResponse, @@ -609,7 +611,7 @@ export const datasource = { } /** - * Get draft workflow + * Get environment variables */ export const get12 = oc .route({ @@ -617,7 +619,7 @@ export const get12 = oc method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariables', path: '/rag/pipelines/{pipeline_id}/workflows/draft/environment-variables', - summary: 'Get draft workflow', + summary: 'Get environment variables', tags: ['console'], }) .input(z.object({ params: zGetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesPath })) @@ -924,7 +926,10 @@ export const patch2 = oc tags: ['console'], }) .input( - z.object({ params: zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath }), + z.object({ + body: zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdBody, + params: zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath, + }), ) .output(zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse) @@ -948,7 +953,7 @@ export const delete4 = oc .output(zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse) /** - * Get draft workflow + * List draft pipeline variables without loading their values */ export const get19 = oc .route({ @@ -956,7 +961,7 @@ export const get19 = oc method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftVariables', path: '/rag/pipelines/{pipeline_id}/workflows/draft/variables', - summary: 'Get draft workflow', + summary: 'List draft pipeline variables without loading their values', tags: ['console'], }) .input( @@ -1000,7 +1005,12 @@ export const post15 = oc summary: 'Sync draft workflow', tags: ['console'], }) - .input(z.object({ params: zPostRagPipelinesByPipelineIdWorkflowsDraftPath })) + .input( + z.object({ + body: zPostRagPipelinesByPipelineIdWorkflowsDraftBody, + params: zPostRagPipelinesByPipelineIdWorkflowsDraftPath, + }), + ) .output(zPostRagPipelinesByPipelineIdWorkflowsDraftResponse) export const draft = { @@ -1034,7 +1044,7 @@ export const get21 = oc .output(zGetRagPipelinesByPipelineIdWorkflowsPublishResponse) /** - * Publish workflow + * Publish a prepared Pipeline and its dataset settings atomically */ export const post16 = oc .route({ @@ -1042,7 +1052,7 @@ export const post16 = oc method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsPublish', path: '/rag/pipelines/{pipeline_id}/workflows/publish', - summary: 'Publish workflow', + summary: 'Publish a prepared Pipeline and its dataset settings atomically', tags: ['console'], }) .input(z.object({ params: zPostRagPipelinesByPipelineIdWorkflowsPublishPath })) @@ -1216,9 +1226,6 @@ export const restore = { post: post20, } -/** - * Delete a published workflow version that is not currently active on the pipeline - */ export const delete5 = oc .route({ inputStructure: 'detailed', @@ -1226,7 +1233,6 @@ export const delete5 = oc operationId: 'deleteRagPipelinesByPipelineIdWorkflowsByWorkflowId', path: '/rag/pipelines/{pipeline_id}/workflows/{workflow_id}', successStatus: 204, - summary: 'Delete a published workflow version that is not currently active on the pipeline', tags: ['console'], }) .input(z.object({ params: zDeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdPath })) diff --git a/packages/contracts/generated/api/console/rag/types.gen.ts b/packages/contracts/generated/api/console/rag/types.gen.ts index 70953beb7726cf..e6186ad9b34a82 100644 --- a/packages/contracts/generated/api/console/rag/types.gen.ts +++ b/packages/contracts/generated/api/console/rag/types.gen.ts @@ -180,6 +180,31 @@ export type WorkflowResponse = { version_number?: number | null } +export type DraftWorkflowSyncPayload = { + conversation_variables?: Array<{ + [key: string]: unknown + }> | null + environment_variables?: Array<{ + [key: string]: unknown + }> | null + features?: { + [key: string]: unknown + } | null + graph: { + [key: string]: unknown + } + hash?: string | null + rag_pipeline_variables?: Array<{ + [key: string]: unknown + }> | null +} + +export type RagPipelineWorkflowSyncResponse = { + hash: string + result: string + updated_at: number +} + export type DatasourceNodeRunPayload = { credential_id?: string | null datasource_type: string @@ -277,6 +302,11 @@ export type WorkflowDraftVariableResponse = { visible: boolean } +export type WorkflowDraftVariablePatchPayload = { + name?: string | null + value?: unknown | null +} + export type RagPipelineWorkflowPublishResponse = { created_at: number result: string @@ -304,12 +334,6 @@ export type PublishedWorkflowRunPayload = { start_node_id: string } -export type RagPipelineWorkflowSyncResponse = { - hash: string - result: string - updated_at: number -} - export type ImportStatus = 'completed' | 'completed-with-warnings' | 'failed' | 'pending' export type DatasetDocMetadataResponse = { @@ -1122,7 +1146,7 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftResponse = GetRagPipelinesByPipelineIdWorkflowsDraftResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftResponses] export type PostRagPipelinesByPipelineIdWorkflowsDraftData = { - body?: never + body: DraftWorkflowSyncPayload path: { pipeline_id: string } @@ -1131,9 +1155,7 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftData = { } export type PostRagPipelinesByPipelineIdWorkflowsDraftResponses = { - 200: { - [key: string]: unknown - } + 200: RagPipelineWorkflowSyncResponse } export type PostRagPipelinesByPipelineIdWorkflowsDraftResponse = @@ -1428,7 +1450,7 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdRespon GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponses] export type PatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdData = { - body?: never + body: WorkflowDraftVariablePatchPayload path: { pipeline_id: string variable_id: string diff --git a/packages/contracts/generated/api/console/rag/zod.gen.ts b/packages/contracts/generated/api/console/rag/zod.gen.ts index a5afe6f494881d..3d92e1996df244 100644 --- a/packages/contracts/generated/api/console/rag/zod.gen.ts +++ b/packages/contracts/generated/api/console/rag/zod.gen.ts @@ -76,6 +76,27 @@ export const zDefaultBlockConfigsResponse = z.array(z.record(z.string(), z.unkno */ export const zDefaultBlockConfigResponse = z.record(z.string(), z.unknown()) +/** + * DraftWorkflowSyncPayload + */ +export const zDraftWorkflowSyncPayload = z.object({ + conversation_variables: z.array(z.record(z.string(), z.unknown())).nullish(), + environment_variables: z.array(z.record(z.string(), z.unknown())).nullish(), + features: z.record(z.string(), z.unknown()).nullish(), + graph: z.record(z.string(), z.unknown()), + hash: z.string().nullish(), + rag_pipeline_variables: z.array(z.record(z.string(), z.unknown())).nullish(), +}) + +/** + * RagPipelineWorkflowSyncResponse + */ +export const zRagPipelineWorkflowSyncResponse = z.object({ + hash: z.string(), + result: z.string(), + updated_at: z.int(), +}) + /** * DatasourceNodeRunPayload */ @@ -126,6 +147,14 @@ export const zDraftWorkflowRunPayload = z.object({ start_node_id: z.string(), }) +/** + * WorkflowDraftVariablePatchPayload + */ +export const zWorkflowDraftVariablePatchPayload = z.object({ + name: z.string().nullish(), + value: z.unknown().nullish(), +}) + /** * RagPipelineWorkflowPublishResponse */ @@ -156,15 +185,6 @@ export const zPublishedWorkflowRunPayload = z.object({ start_node_id: z.string(), }) -/** - * RagPipelineWorkflowSyncResponse - */ -export const zRagPipelineWorkflowSyncResponse = z.object({ - hash: z.string(), - result: z.string(), - updated_at: z.int(), -}) - /** * ImportStatus */ @@ -1169,6 +1189,8 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftPath = z.object({ */ export const zGetRagPipelinesByPipelineIdWorkflowsDraftResponse = zWorkflowResponse +export const zPostRagPipelinesByPipelineIdWorkflowsDraftBody = zDraftWorkflowSyncPayload + export const zPostRagPipelinesByPipelineIdWorkflowsDraftPath = z.object({ pipeline_id: z.uuid(), }) @@ -1176,7 +1198,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftPath = z.object({ /** * Success */ -export const zPostRagPipelinesByPipelineIdWorkflowsDraftResponse = z.record(z.string(), z.unknown()) +export const zPostRagPipelinesByPipelineIdWorkflowsDraftResponse = zRagPipelineWorkflowSyncResponse export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunBody = zDatasourceNodeRunPayload @@ -1382,6 +1404,9 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse = zWorkflowDraftVariableResponse +export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdBody = + zWorkflowDraftVariablePatchPayload + export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath = z.object({ pipeline_id: z.uuid(), variable_id: z.uuid(), diff --git a/packages/contracts/generated/api/console/snippets/orpc.gen.ts b/packages/contracts/generated/api/console/snippets/orpc.gen.ts index 4e664987a77a71..219e988ec2b34a 100644 --- a/packages/contracts/generated/api/console/snippets/orpc.gen.ts +++ b/packages/contracts/generated/api/console/snippets/orpc.gen.ts @@ -877,7 +877,7 @@ export const restore = { } /** - * Delete a published snippet workflow version + * Delete a version and retire its unowned Agents through the shared use case * * Delete a published snippet workflow version */ @@ -889,7 +889,7 @@ export const delete4 = oc operationId: 'deleteSnippetsBySnippetIdWorkflowsByWorkflowId', path: '/snippets/{snippet_id}/workflows/{workflow_id}', successStatus: 204, - summary: 'Delete a published snippet workflow version', + summary: 'Delete a version and retire its unowned Agents through the shared use case', tags: ['console'], }) .input(z.object({ params: zDeleteSnippetsBySnippetIdWorkflowsByWorkflowIdPath })) From 3606b17d2eb7d2e5a47aab27ec20ff9a2681d06a Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Thu, 8 Oct 2026 19:39:30 +0800 Subject: [PATCH 3/7] fix(tests): preserve migrated dependencies when merging main --- .../app/apps/test_workflow_app_runner_single_node.py | 2 +- .../app/apps/workflow/test_generate_task_pipeline.py | 2 +- .../test_knowledge_retrieval_node.py | 9 +++++++-- .../nodes/llm/test_llm_node_execution_behavior.py | 12 ++++++------ .../unit_tests/core/workflow/nodes/llm/test_node.py | 6 +++++- .../core/workflow/nodes/tool/test_tool_node.py | 2 +- .../services/test_workflow_collaboration_service.py | 5 ++--- .../services/workflow_collaboration_fixtures.py | 2 +- 8 files changed, 24 insertions(+), 16 deletions(-) diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py index 8edcb0321470c6..ad4b50db289d32 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py @@ -7,7 +7,6 @@ from pydantic import ValidationError from core.app.app_config.entities import WorkflowUIBasedAppConfig -from core.app.apps.workflow.app_queue_manager import WorkflowAppQueueManager from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from core.credit_usage import CreditUsageAppType from core.workflow.system_variables import default_system_variables @@ -15,6 +14,7 @@ from models.model import AppMode from models.workflow import Workflow, WorkflowKind from services.workflow.execution.adapters.graph import WorkflowGraphBuilder +from services.workflow.execution.adapters.workflow.app_queue_manager import WorkflowAppQueueManager from services.workflow.execution.adapters.workflow.app_runner import WorkflowAppRunner from services.workflow.execution.ports import WorkflowRuntime diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py index 5395888853dc98..c5d86062dc04cc 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py @@ -7,7 +7,6 @@ from core.app.app_config.entities import WorkflowUIBasedAppConfig from core.app.apps.draft_variable_saver import NoopDraftVariableSaver -from core.app.apps.workflow.app_queue_manager import WorkflowAppQueueManager from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from core.app.entities.queue_entities import QueueWorkflowStartedEvent from core.workflow.system_variables import build_system_variables @@ -19,6 +18,7 @@ from models.enums import CreatorUserRole from models.model import AppMode from models.workflow import WorkflowAppLog +from services.workflow.execution.adapters.workflow.app_queue_manager import WorkflowAppQueueManager from services.workflow.execution.adapters.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline from tests.unit_tests.model_factories import make_account, make_end_user, make_workflow from tests.workflow_test_utils import build_test_variable_pool diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py index f4008e8360d7ac..193312df81b141 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py @@ -5,7 +5,6 @@ from pytest_mock import MockerFixture from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom -from core.rag.retrieval.dataset_retrieval import DatasetRetrieval from core.workflow.nodes.knowledge_retrieval.entities import ( Condition, KnowledgeRetrievalNodeData, @@ -21,6 +20,8 @@ from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.runtime import GraphRuntimeState, VariablePool from graphon.variables import ArrayObjectSegment, StringSegment +from services.knowledge.retrieval.dataset_retrieval import DatasetRetrieval +from services.knowledge.retrieval.ports import KnowledgeRetrievalRecords, RetrievalReranker, RetrievalThreadFactory from services.workflow.execution.adapters.knowledge_retrieval import ( KnowledgeRetrievalNode, _normalize_metadata_filter_scalar, @@ -73,7 +74,11 @@ def mock_graph_runtime_state(): @pytest.fixture def mock_rag_retrieval(mocker: MockerFixture): """Keep real usage and request metadata while isolating retrieval I/O.""" - mock_retrieval = DatasetRetrieval() + mock_retrieval = DatasetRetrieval( + records=mocker.create_autospec(KnowledgeRetrievalRecords, instance=True), + rerank=mocker.create_autospec(RetrievalReranker, instance=True), + thread=mocker.create_autospec(RetrievalThreadFactory, instance=True), + ) mocker.patch.object(mock_retrieval, "knowledge_retrieval", return_value=[]) return mock_retrieval diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_node_execution_behavior.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_node_execution_behavior.py index 32334e561fde86..7c8c65052c3c35 100644 --- a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_node_execution_behavior.py +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_node_execution_behavior.py @@ -12,12 +12,6 @@ from core.app.llm.model_access import DifyCredentialsProvider, DifyModelFactory from core.helper.ssrf_proxy import graphon_ssrf_proxy from core.llm_generator.output_parser.structured_output import _parse_structured_output -from core.workflow.node_runtime import ( - DifyPreparedLLM, - DifyPromptMessageSerializer, - build_dify_llm_file_saver, - resolve_dify_run_context, -) from core.workflow.system_variables import build_system_variables from extensions.ext_database import db from graphon.enums import WorkflowNodeExecutionStatus @@ -26,6 +20,12 @@ from graphon.nodes.llm.entities import LLMNodeData from graphon.nodes.llm.node import LLMNode from graphon.runtime import GraphRuntimeState, VariablePool +from services.workflow.execution.adapters.node_runtime import ( + DifyPreparedLLM, + DifyPromptMessageSerializer, + build_dify_llm_file_saver, + resolve_dify_run_context, +) from tests.unit_tests.core.model_fixtures import make_model_config, make_model_instance from tests.workflow_test_utils import build_test_graph_init_params diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py index 090155bc2da30e..6cfbe5e325b4b6 100644 --- a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py @@ -19,7 +19,6 @@ from core.model_manager import ModelInstance from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly from core.prompt.entities.advanced_prompt_entities import MemoryConfig -from core.workflow.node_runtime import DifyFileReferenceFactory, DifyPromptMessageSerializer, DifyToolFileManager from core.workflow.system_variables import default_system_variables from graphon.entities import GraphInitParams from graphon.file import File, FileTransferMethod, FileType @@ -88,6 +87,11 @@ from graphon.template_rendering import TemplateRenderError from graphon.variables import ArrayAnySegment, ArrayFileSegment, NoneSegment from models.provider import ProviderType +from services.workflow.execution.adapters.node_runtime import ( + DifyFileReferenceFactory, + DifyPromptMessageSerializer, + DifyToolFileManager, +) from tests.workflow_test_utils import build_test_graph_init_params diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py index 7c3d6ba7a08e7d..5530190d409f3e 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py @@ -11,7 +11,6 @@ from pytest_mock import MockerFixture from core.tools.tool_file_manager import ToolFileManager -from core.workflow.node_runtime import DifyToolFileManager from core.workflow.system_variables import build_system_variables from graphon.file import File, FileTransferMethod, FileType from graphon.model_runtime.entities.llm_entities import LLMUsage @@ -20,6 +19,7 @@ from graphon.nodes.tool_runtime_entities import ToolRuntimeHandle, ToolRuntimeMessage from graphon.runtime import GraphRuntimeState from graphon.variables.segments import ArrayFileSegment +from services.workflow.execution.adapters.node_runtime import DifyToolFileManager from tests.workflow_test_utils import build_test_graph_init_params, build_test_variable_pool if TYPE_CHECKING: # pragma: no cover - imported for type checking only diff --git a/api/tests/unit_tests/services/test_workflow_collaboration_service.py b/api/tests/unit_tests/services/test_workflow_collaboration_service.py index 39b132794d62d1..c1bc18be04f4c9 100644 --- a/api/tests/unit_tests/services/test_workflow_collaboration_service.py +++ b/api/tests/unit_tests/services/test_workflow_collaboration_service.py @@ -24,18 +24,17 @@ @pytest.fixture def collaboration_redis( - redis_transport: tuple[RedisClientWrapper, MagicMock], monkeypatch: pytest.MonkeyPatch + redis_transport: tuple[RedisClientWrapper, MagicMock], ) -> RedisClientWrapper: client, commands = redis_transport commands.side_effect = RedisState().execute - monkeypatch.setattr("repositories.workflow_collaboration_repository.redis_client", client) return client @pytest.fixture def real_service(collaboration_redis: RedisClientWrapper) -> ServiceFixture: assert collaboration_redis._require_client() is not None - repository = WorkflowCollaborationRepository() + repository = WorkflowCollaborationRepository(redis=collaboration_redis) socketio = Mock() return WorkflowCollaborationService(repository, socketio, server_id="server-1"), repository, socketio diff --git a/api/tests/unit_tests/services/workflow_collaboration_fixtures.py b/api/tests/unit_tests/services/workflow_collaboration_fixtures.py index 5d3f9bee49b7a3..099cd8002d97ce 100644 --- a/api/tests/unit_tests/services/workflow_collaboration_fixtures.py +++ b/api/tests/unit_tests/services/workflow_collaboration_fixtures.py @@ -6,7 +6,7 @@ from redis.lock import Lock -from repositories.workflow_collaboration_repository import ( +from repositories.workflow.collaboration_repository import ( _UPDATE_SESSION_GRAPH_ACTIVE_LUA, WorkflowCollaborationRepository, WorkflowSessionInfo, From fd7d0536ab8967fcb5c8fde2bd1237cff7d94fd3 Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Fri, 9 Oct 2026 14:12:19 +0800 Subject: [PATCH 4/7] fix: adapt retained callers while retiring workflow implementations --- api/.importlinter | 17 ++++++++- api/core/app/apps/advanced_chat/app_runner.py | 5 +-- api/core/app/apps/agent_app/app_generator.py | 13 +++++-- .../apps/agent_app/runtime_request_builder.py | 19 +++++----- .../common/workflow_response_converter.py | 2 +- api/core/app/apps/pipeline/pipeline_runner.py | 9 +++-- api/core/app/apps/workflow/app_generator.py | 2 +- api/core/app/apps/workflow/app_runner.py | 5 +-- api/core/app/apps/workflow_app_runner.py | 35 +++++++------------ api/core/app/workflow/layers/persistence.py | 2 +- .../repositories/human_input_repository.py | 33 +++-------------- .../agent/legacy_workflow_publish_service.py | 8 +++-- 12 files changed, 74 insertions(+), 76 deletions(-) diff --git a/api/.importlinter b/api/.importlinter index cbd52eccb02c48..b03243ba1925aa 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -39,15 +39,21 @@ ignore_imports = core.app.apps.advanced_chat.app_generator -> services.errors.conversation core.app.apps.advanced_chat.app_generator -> services.workflow_draft_variable_service core.app.apps.advanced_chat.app_runner -> services.conversation_variable_updater + core.app.apps.advanced_chat.app_runner -> services.workflow.execution.adapters.node_factory + core.app.apps.advanced_chat.app_runner -> services.workflow.execution.adapters.workflow_entry core.app.apps.agent_app.app_generator -> services.agent.composer_service core.app.apps.agent_app.app_generator -> services.agent.workspace_service core.app.apps.agent_app.app_generator -> services.conversation_service + core.app.apps.agent_app.app_generator -> services.workflow.execution.adapters.agent_v2.dify_tools_builder core.app.apps.agent_app.runtime_request_builder -> services.agent.prompt_mentions + core.app.apps.agent_app.runtime_request_builder -> services.workflow.execution.adapters.agent_v2.dify_tools_builder + core.app.apps.agent_app.runtime_request_builder -> services.workflow.execution.adapters.agent_v2.runtime_request_builder core.app.apps.agent_app.session_store -> services.agent.workspace_service core.app.apps.agent_chat.app_generator -> services.conversation_service core.app.apps.base_app_generator -> services.workflow_draft_variable_service core.app.apps.chat.app_generator -> services.conversation_service core.app.apps.common.workflow_response_converter -> services.variable_truncator + core.app.apps.common.workflow_response_converter -> services.workflow.execution.adapters.workflow_entry core.app.apps.completion.app_generator -> services.errors.app core.app.apps.completion.app_generator -> services.errors.message core.app.apps.message_based_app_generator -> services.errors.app_model_config @@ -58,14 +64,23 @@ ignore_imports = core.app.apps.pipeline.pipeline_generator -> services.data_source.provider_service core.app.apps.pipeline.pipeline_generator -> services.feature_service core.app.apps.pipeline.pipeline_generator -> services.workflow_draft_variable_service + core.app.apps.pipeline.pipeline_runner -> services.workflow.execution.adapters.node_factory + core.app.apps.pipeline.pipeline_runner -> services.workflow.execution.adapters.workflow_entry core.app.apps.workflow.app_generator -> controllers.console.app.workflow core.app.apps.workflow.app_generator -> services.snippet_generate_service + core.app.apps.workflow.app_generator -> services.workflow.execution.adapters.node_factory core.app.apps.workflow.app_generator -> services.workflow_draft_variable_service - core.app.apps.workflow_app_runner -> tasks.mail_human_input_delivery_task + core.app.apps.workflow.app_runner -> services.workflow.execution.adapters.node_factory + core.app.apps.workflow.app_runner -> services.workflow.execution.adapters.workflow_entry + core.app.apps.workflow_app_runner -> services.workflow.execution.adapters.events + core.app.apps.workflow_app_runner -> services.workflow.execution.adapters.human_input_events + core.app.apps.workflow_app_runner -> services.workflow.execution.adapters.node_factory + core.app.apps.workflow_app_runner -> services.workflow.execution.adapters.workflow_entry core.app.features.annotation_reply.annotation_reply -> services.annotation_service core.app.llm.quota -> services.credit_pool_service core.app.task_pipeline.message_cycle_manager -> services.annotation_service core.app.workflow.layers.persistence -> services.workflow.inspector_events + core.app.workflow.layers.persistence -> services.workflow.execution.adapters.workflow_entry core.helper.credential_utils -> services.enterprise.plugin_manager_service core.helper.credential_utils -> services.system_feature_service core.mcp.auth_client -> services.tools.mcp_tools_manage_service diff --git a/api/core/app/apps/advanced_chat/app_runner.py b/api/core/app/apps/advanced_chat/app_runner.py index 30470c0383b4cd..b11ae03b96e429 100644 --- a/api/core/app/apps/advanced_chat/app_runner.py +++ b/api/core/app/apps/advanced_chat/app_runner.py @@ -34,7 +34,6 @@ from core.moderation.base import ModerationError from core.moderation.input_moderation import InputModeration from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository -from core.workflow.node_factory import get_default_root_node_id from core.workflow.nodes.agent_v2.workspace_retirement_layer import build_workflow_agent_workspace_retirement_layer from core.workflow.system_variables import ( build_bootstrap_variables, @@ -42,7 +41,6 @@ system_variables_to_mapping, ) from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool -from core.workflow.workflow_entry import WorkflowEntry from extensions.ext_redis import redis_client from extensions.otel import WorkflowAppRunnerHandler, trace_span from extensions.workflow_warm_shutdown import WORKFLOW_WARM_SHUTDOWN_ABORT_REASON, celery_warm_shutdown_started @@ -57,6 +55,8 @@ from models.model import App, Conversation, Message, MessageAnnotation from models.workflow import ConversationVariable from services.conversation_variable_updater import ConversationVariableUpdater +from services.workflow.execution.adapters.node_factory import get_default_root_node_id +from services.workflow.execution.adapters.workflow_entry import WorkflowEntry logger = logging.getLogger(__name__) @@ -245,6 +245,7 @@ def run(self): ) workflow_entry = WorkflowEntry( + human_form_reader=self._human_form_reader, tenant_id=self._workflow.tenant_id, app_id=self._workflow.app_id, workflow_id=self._workflow.id, diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index 2d58ca1b4e4363..581c4821811b35 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -19,7 +19,7 @@ from flask import Flask, current_app from sqlalchemy import and_, or_, select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from clients.agent_backend import AgentBackendRunEventAdapter from clients.agent_backend.factory import create_agent_backend_run_client @@ -69,8 +69,11 @@ ) from models.agent_config_entities import AgentSoulConfig from models.model import load_annotation_reply_config +from repositories.tools.provider_repository import ToolProviderRepository +from repositories.tools.workflow_repository import WorkflowToolRepository from services.agent.workspace_service import AgentWorkspaceService, WorkspaceOwnerScope from services.conversation_service import ConversationService +from services.workflow.execution.adapters.agent_v2.dify_tools_builder import WorkflowAgentDifyToolsBuilder logger = logging.getLogger(__name__) @@ -515,8 +518,14 @@ def _require_query(args: Mapping[str, Any]) -> str: @staticmethod def _build_runner() -> AgentAppRunner: + database_client = sessionmaker(bind=db.engine, expire_on_commit=False) return AgentAppRunner( - request_builder=AgentAppRuntimeRequestBuilder(), + request_builder=AgentAppRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=ToolProviderRepository(database_client), + workflow_queries=WorkflowToolRepository(database_client), + ) + ), agent_backend_client=create_agent_backend_run_client( base_url=dify_config.AGENT_BACKEND_BASE_URL, api_token=dify_config.AGENT_BACKEND_API_TOKEN, diff --git a/api/core/app/apps/agent_app/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py index 5f05b0c99d660d..6f9dc1f5233f20 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -38,13 +38,17 @@ from core.app.llm.model_access import resolve_model_context_window, resolve_model_supports_vision from core.plugin.provider_identity import normalize_plugin_daemon_provider_identity from core.workflow.file_reference import build_file_reference, is_canonical_file_reference -from core.workflow.nodes.agent_v2.dify_tools_builder import ( +from graphon.file import File, FileTransferMethod, FileType, file_manager +from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent +from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig +from models.provider_ids import ModelProviderID +from services.agent.prompt_mentions import expand_prompt_mentions +from services.workflow.execution.adapters.agent_v2.dify_tools_builder import ( WorkflowAgentDifyToolLayersBuilder, - WorkflowAgentDifyToolsBuilder, WorkflowAgentDifyToolsBuildError, WorkflowAgentToolLayers, ) -from core.workflow.nodes.agent_v2.runtime_request_builder import ( +from services.workflow.execution.adapters.agent_v2.runtime_request_builder import ( append_runtime_warnings, build_ask_human_layer_config, build_config_aware_soul_mention_resolver, @@ -53,11 +57,6 @@ build_shell_layer_config, load_runtime_agent_skill_configs, ) -from graphon.file import File, FileTransferMethod, FileType, file_manager -from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent -from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig -from models.provider_ids import ModelProviderID -from services.agent.prompt_mentions import expand_prompt_mentions from .errors import AgentSessionSnapshotIncompatibleError @@ -107,10 +106,10 @@ def __init__( self, *, request_builder: AgentBackendRunRequestBuilder | None = None, - dify_tools_builder: WorkflowAgentDifyToolLayersBuilder | None = None, + dify_tools_builder: WorkflowAgentDifyToolLayersBuilder, ) -> None: self._request_builder = request_builder or AgentBackendRunRequestBuilder() - self._dify_tools_builder = dify_tools_builder or WorkflowAgentDifyToolsBuilder() + self._dify_tools_builder = dify_tools_builder def build(self, context: AgentAppRuntimeBuildContext) -> AgentAppRuntimeRequest: agent_soul = context.agent_soul diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index e95a68c0b4480a..238a5879bd9b87 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -62,7 +62,6 @@ ) from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from core.workflow.system_variables import SystemVariableKey, system_variables_to_mapping -from core.workflow.workflow_entry import WorkflowEntry from extensions.ext_database import db from graphon.entities import WorkflowStartReason from graphon.enums import ( @@ -81,6 +80,7 @@ from models.human_input import HumanInputForm from models.workflow import WorkflowRun from services.variable_truncator import BaseTruncator, DummyVariableTruncator, VariableTruncator +from services.workflow.execution.adapters.workflow_entry import WorkflowEntry # Maps the entry surface a workflow was invoked from to the HITL surface that # its resume tokens must be filtered for. Surfaces not in this map fall back to diff --git a/api/core/app/apps/pipeline/pipeline_runner.py b/api/core/app/apps/pipeline/pipeline_runner.py index 5a3f61422013f7..8f82edd3b01f0f 100644 --- a/api/core/app/apps/pipeline/pipeline_runner.py +++ b/api/core/app/apps/pipeline/pipeline_runner.py @@ -18,10 +18,8 @@ from core.credit_usage import CreditUsageAppType from core.db.session_factory import create_session from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository -from core.workflow.node_factory import DifyGraphInitContext, DifyNodeFactory, get_default_root_node_id from core.workflow.system_variables import build_bootstrap_variables, build_system_variables from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool -from core.workflow.workflow_entry import WorkflowEntry from graphon.enums import WorkflowType from graphon.graph import Graph from graphon.graph_events import GraphEngineEvent, GraphRunFailedEvent @@ -31,6 +29,12 @@ from models.dataset import Dataset, Pipeline from models.model import EndUser from models.workflow import Workflow +from services.workflow.execution.adapters.node_factory import ( + DifyGraphInitContext, + DifyNodeFactory, + get_default_root_node_id, +) +from services.workflow.execution.adapters.workflow_entry import WorkflowEntry logger = logging.getLogger(__name__) @@ -212,6 +216,7 @@ def run(self) -> None: # RUN WORKFLOW workflow_entry = WorkflowEntry( + human_form_reader=self._human_form_reader, tenant_id=workflow.tenant_id, app_id=workflow.app_id, workflow_id=workflow.id, diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index 65d79dd0d35d36..3abeaf5b7d5cd8 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/api/core/app/apps/workflow/app_generator.py @@ -42,7 +42,6 @@ from core.repositories import DifyCoreRepositoryFactory from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository from core.trigger.constants import is_trigger_node_type -from core.workflow.node_factory import get_default_root_node_id from extensions.ext_database import db from factories import file_factory from graphon.filters import ResponseStreamFilter @@ -55,6 +54,7 @@ from models.enums import WorkflowRunTriggeredFrom from models.model import App, EndUser from models.workflow import Workflow, WorkflowNodeExecutionTriggeredFrom +from services.workflow.execution.adapters.node_factory import get_default_root_node_id from services.workflow_draft_variable_service import DraftVarLoader, WorkflowDraftVariableService if TYPE_CHECKING: diff --git a/api/core/app/apps/workflow/app_runner.py b/api/core/app/apps/workflow/app_runner.py index df545d0e34554b..ce27399a39e48b 100644 --- a/api/core/app/apps/workflow/app_runner.py +++ b/api/core/app/apps/workflow/app_runner.py @@ -21,12 +21,10 @@ ) from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository -from core.workflow.node_factory import get_default_root_node_id from core.workflow.nodes.agent_v2.workspace_retirement_layer import build_workflow_agent_workspace_retirement_layer from core.workflow.snippet_start import get_compatible_start_aliases from core.workflow.system_variables import build_bootstrap_variables, build_system_variables from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool -from core.workflow.workflow_entry import WorkflowEntry from extensions.ext_redis import redis_client from extensions.otel import WorkflowAppRunnerHandler, trace_span from extensions.workflow_warm_shutdown import WORKFLOW_WARM_SHUTDOWN_ABORT_REASON, celery_warm_shutdown_started @@ -38,6 +36,8 @@ from graphon.variable_loader import VariableLoader from libs.datetime_utils import naive_utc_now from models.workflow import Workflow +from services.workflow.execution.adapters.node_factory import get_default_root_node_id +from services.workflow.execution.adapters.workflow_entry import WorkflowEntry logger = logging.getLogger(__name__) @@ -181,6 +181,7 @@ def run(self): self._queue_manager.graph_runtime_state = graph_runtime_state workflow_entry = WorkflowEntry( + human_form_reader=self._human_form_reader, tenant_id=self._workflow.tenant_id, app_id=self._workflow.app_id, workflow_id=self._workflow.id, diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index e4759d28ec223f..317463168cdf70 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -37,15 +37,7 @@ from core.credit_usage import CreditUsageAppType from core.rag.entities import RetrievalSourceMetadata from core.repositories.human_input_repository import HumanInputFormSubmissionRepository -from core.workflow.node_factory import ( - DifyGraphInitContext, - DifyNodeFactory, - get_default_root_node_id, - resolve_workflow_node_class, -) from core.workflow.nodes.agent.events import NodeRunAgentLogEvent -from core.workflow.nodes.human_input.boundary import enrich_graph_pause_reasons -from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from core.workflow.system_variables import ( build_bootstrap_variables, default_system_variables, @@ -54,7 +46,6 @@ preload_node_creation_variables, ) from core.workflow.variable_pool_initializer import add_variables_to_pool -from core.workflow.workflow_entry import WorkflowEntry from core.workflow.workflow_run_outputs import project_node_outputs_for_workflow_run from graphon.entities.base_node_data import BaseNodeData from graphon.entities.graph_config import NodeConfigDict @@ -92,7 +83,15 @@ from graphon.runtime import GraphRuntimeState, VariablePool from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_into_variable_pool from models.workflow import Workflow -from tasks.mail_human_input_delivery_task import dispatch_human_input_email_task +from services.workflow.execution.adapters.events import enqueue_human_input_notifications +from services.workflow.execution.adapters.human_input_events import enrich_graph_pause_reasons +from services.workflow.execution.adapters.node_factory import ( + DifyGraphInitContext, + DifyNodeFactory, + get_default_root_node_id, + resolve_workflow_node_class, +) +from services.workflow.execution.adapters.workflow_entry import WorkflowEntry logger = logging.getLogger(__name__) @@ -171,6 +170,7 @@ def __init__( self._variable_loader = variable_loader self._app_id = app_id self._graph_engine_layers = graph_engine_layers + self._human_form_reader = HumanInputFormSubmissionRepository() @staticmethod def _resolve_user_from(invoke_from: InvokeFrom) -> UserFrom: @@ -497,7 +497,7 @@ def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent): ) enriched_reasons = enrich_graph_pause_reasons( reasons=event.reasons, - form_repository=HumanInputFormSubmissionRepository(), + form_repository=self._human_form_reader, variable_pool=runtime_state.variable_pool, ) self._enqueue_human_input_notifications(enriched_reasons) @@ -781,18 +781,7 @@ def _handle_event(self, workflow_entry: WorkflowEntry, event: GraphEngineEvent): ) def _enqueue_human_input_notifications(self, reasons: Sequence[object]) -> None: - for reason in reasons: - if not isinstance(reason, HumanInputRequired): - continue - if not reason.form_id: - continue - try: - dispatch_human_input_email_task.apply_async( - kwargs={"form_id": reason.form_id, "node_title": reason.node_title}, - queue="mail", - ) - except Exception: # pragma: no cover - defensive logging - logger.exception("Failed to enqueue human input email task for form %s", reason.form_id) + enqueue_human_input_notifications(reasons) def _publish_event(self, event: AppQueueEvent): self._queue_manager.publish(event, PublishFrom.APPLICATION_MANAGER) diff --git a/api/core/app/workflow/layers/persistence.py b/api/core/app/workflow/layers/persistence.py index 8b04661deb3c7a..4bca3dbd71ace2 100644 --- a/api/core/app/workflow/layers/persistence.py +++ b/api/core/app/workflow/layers/persistence.py @@ -348,7 +348,7 @@ def _prepare_workflow_inputs(self) -> Mapping[str, Any]: continue inputs[f"sys.{field_name}"] = value # Local import to avoid circular dependency during app bootstrapping. - from core.workflow.workflow_entry import WorkflowEntry + from services.workflow.execution.adapters.workflow_entry import WorkflowEntry handled = WorkflowEntry.handle_special_values(inputs) return handled or {} diff --git a/api/core/repositories/human_input_repository.py b/api/core/repositories/human_input_repository.py index 480b2e27a2d778..40092ca0e2df35 100644 --- a/api/core/repositories/human_input_repository.py +++ b/api/core/repositories/human_input_repository.py @@ -33,6 +33,9 @@ from models.human_input_contracts import ( HumanInputFormEntity as SharedHumanInputFormEntity, ) +from models.human_input_contracts import ( + HumanInputFormRecord as SharedHumanInputFormRecord, +) from models.human_input_contracts import ( HumanInputFormRepository as SharedHumanInputFormRepository, ) @@ -167,34 +170,8 @@ def expiration_time(self) -> datetime: @dataclasses.dataclass(frozen=True) -class HumanInputFormRecord: - form_id: str - workflow_run_id: str | None - node_id: str - tenant_id: str - app_id: str - form_kind: HumanInputFormKind - definition: FormDefinition - rendered_content: str - created_at: datetime - expiration_time: datetime - status: HumanInputFormStatus - selected_action_id: str | None - submitted_data: Mapping[str, Any] | None - submitted_at: datetime | None - submission_user_id: str | None - submission_end_user_id: str | None - completed_by_recipient_id: str | None - recipient_id: str | None - recipient_type: RecipientType | None - access_token: str | None - # ENG-635: Agent v2 chat owner (NULL for workflow-owned forms). Trailing + - # defaulted so existing record constructions stay source-compatible. - conversation_id: str | None = None - - @property - def submitted(self) -> bool: - return self.submitted_at is not None +class HumanInputFormRecord(SharedHumanInputFormRecord): + """Legacy constructor kept on the shared human-input record contract.""" @classmethod def from_models( diff --git a/api/services/agent/legacy_workflow_publish_service.py b/api/services/agent/legacy_workflow_publish_service.py index 07116626d9f256..2e78b5dd7dd94c 100644 --- a/api/services/agent/legacy_workflow_publish_service.py +++ b/api/services/agent/legacy_workflow_publish_service.py @@ -4,7 +4,6 @@ from collections.abc import Mapping from typing import Any, cast -from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError, WorkflowAgentNodeValidator from pydantic import ValidationError from sqlalchemy import select from sqlalchemy.orm import Session @@ -31,6 +30,7 @@ extract_workflow_node_output_selectors, workflow_previous_node_output_refs_from_selectors, ) +from services.agent.workflow_validator import WorkflowAgentNodeValidationError, WorkflowAgentNodeValidator from services.entities.agent_entities import ( ComposerSavePayload, ComposerSaveStrategy, @@ -110,12 +110,14 @@ def project_draft_bindings_to_graph(cls, *, session: Session, draft_workflow: Wo @classmethod def validate_agent_nodes_for_publish(cls, *, session: Session, draft_workflow: Workflow) -> None: - WorkflowAgentNodeValidator.validate_published_workflow(session=session, workflow=draft_workflow) + validator = WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(session)) + validator.validate_published_workflow(workflow=workflow_binding_scope(draft_workflow)) cls._validate_composer_configs_for_publish(session=session, draft_workflow=draft_workflow) @classmethod def validate_agent_nodes_for_draft_sync(cls, *, session: Session, draft_workflow: Workflow) -> None: - WorkflowAgentNodeValidator.validate_draft_workflow(session=session, workflow=draft_workflow) + validator = WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(session)) + validator.validate_draft_workflow(workflow=workflow_binding_scope(draft_workflow)) @classmethod def _validate_composer_configs_for_publish(cls, *, session: Session, draft_workflow: Workflow) -> None: From 6d21394056b06d045dc29870d4f85e4820f793c5 Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Tue, 20 Oct 2026 11:50:24 +0800 Subject: [PATCH 5/7] test(api): adapt tests to merged execution interfaces --- .../nodes/agent_v2/test_agent_node.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py index e40d08c74b2035..0eea06aefb1647 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py @@ -387,12 +387,18 @@ def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> Ca def _drain_event_stream(node: DifyAgentNode): - return node._consume_event_stream( + stream = node._consume_event_stream( "run-1", inputs={}, process_data={"workflow_agent_binding_id": "binding-1"}, metadata={"agent_backend": {}}, + stream_text=False, ) + while True: + try: + next(stream) + except StopIteration as stop: + return stop.value def _node( @@ -642,12 +648,15 @@ def test_agent_node_passes_execution_id_to_session_store_and_runtime_request_bui assert context.node_execution_id == execution_id -def test_agent_node_records_agent_message_delta_without_text_stream(tool_providers): +def test_agent_node_run_streams_agent_message_delta_as_text_chunks(tool_providers): events = list(_node(tool_providers=tool_providers, agent_backend_client=AgentMessageDeltaBackendClient())._run()) - assert not any(isinstance(event, StreamChunkEvent) for event in events) - assert len(events) == 1 - result = cast(StreamCompletedEvent, events[0]).node_run_result + assert events[:2] == [ + StreamChunkEvent(selector=["agent-node", "text"], chunk="hello ", is_final=False), + StreamChunkEvent(selector=["agent-node", "text"], chunk="", is_final=True), + ] + assert len(events) == 3 + result = cast(StreamCompletedEvent, events[2]).node_run_result assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.outputs == {"text": "hello agent"} agent_backend = result.metadata[WorkflowNodeExecutionMetadataKey.AGENT_LOG]["agent_backend"] From 318b631f1287c9e243c86794946b3eb0de1c03eb Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Mon, 26 Oct 2026 23:17:12 +0800 Subject: [PATCH 6/7] Keep dormant execution adapters compatible with canonical runtime owners --- api/.importlinter | 16 +- .../app/apps/advanced_chat/app_generator.py | 786 +-------------- .../app/apps/pipeline/pipeline_generator.py | 910 +----------------- api/core/plugin/backwards_invocation/app.py | 341 +------ api/core/tools/workflow_as_tool/tool.py | 488 ++-------- 5 files changed, 95 insertions(+), 2446 deletions(-) diff --git a/api/.importlinter b/api/.importlinter index 57b56d22af6ae3..013186050036b5 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -33,10 +33,8 @@ layers = unmatched_ignore_imports_alerting = error ignore_imports = core.app.app_config.easy_ui_based_app.dataset.manager -> services.knowledge.dataset_service - core.app.apps.advanced_chat.app_generator -> controllers.console.app.workflow - core.app.apps.advanced_chat.app_generator -> services.conversation_service - core.app.apps.advanced_chat.app_generator -> services.errors.conversation - core.app.apps.advanced_chat.app_generator -> services.workflow_draft_variable_service + # Thin legacy generator modules re-export canonical service adapters during path migration. + core.app.apps.advanced_chat.app_generator -> services.workflow.execution.adapters.chatflow.app_generator core.app.apps.advanced_chat.app_runner -> services.workflow.execution.adapters.chatflow.app_runner core.app.apps.agent_app.app_generator -> services.agent.composer_service core.app.apps.agent_app.app_generator -> services.agent.workspace_service @@ -54,16 +52,10 @@ ignore_imports = core.app.apps.chat.app_generator -> services.conversation_service core.app.apps.common.workflow_response_converter -> services.variable_truncator core.app.apps.common.workflow_response_converter -> services.workflow.execution.adapters.workflow_entry - core.app.apps.completion.app_generator -> services.errors.app - core.app.apps.completion.app_generator -> services.errors.message core.app.apps.message_based_app_generator -> services.errors.app_model_config core.app.apps.message_based_app_generator -> services.errors.conversation core.app.apps.message_based_app_generator -> services.errors.message - core.app.apps.pipeline.pipeline_generator -> services.rag_pipeline.rag_pipeline_task_proxy - core.app.apps.pipeline.pipeline_generator -> services.knowledge.dataset_service - core.app.apps.pipeline.pipeline_generator -> services.data_source.provider_service - core.app.apps.pipeline.pipeline_generator -> services.feature_service - core.app.apps.pipeline.pipeline_generator -> services.workflow_draft_variable_service + core.app.apps.pipeline.pipeline_generator -> services.workflow.execution.adapters.pipeline.pipeline_generator core.app.apps.pipeline.pipeline_runner -> services.workflow.execution.adapters.pipeline.pipeline_runner core.app.apps.workflow.app_generator -> services.workflow.execution.adapters.workflow.app_generator core.app.apps.workflow.app_runner -> services.workflow.execution.adapters.workflow.app_runner @@ -75,6 +67,8 @@ ignore_imports = core.helper.credential_utils -> services.enterprise.plugin_manager_service core.helper.credential_utils -> services.system_feature_service core.mcp.auth_client -> services.tools.mcp_tools_manage_service + # Type-only tracing port reference until CoreOps contracts migrate; no runtime service import. + core.ops.base_trace_instance -> services.workflow.execution.adapters.agent_v2.runtime_request_builder core.provider_manager -> services.credit_pool_service core.provider_manager -> services.feature_service core.rag.datasource.retrieval_service -> services.knowledge.external.service diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py index 1b3f52f6bf5387..65e1b4f6d77016 100644 --- a/api/core/app/apps/advanced_chat/app_generator.py +++ b/api/core/app/apps/advanced_chat/app_generator.py @@ -1,785 +1,5 @@ -from __future__ import annotations +"""Compatibility import for the chatflow generation service adapter.""" -import contextvars -import logging -import threading -import uuid -from collections.abc import Generator, Mapping -from typing import TYPE_CHECKING, Any, Literal, overload +from services.workflow.execution.adapters.chatflow.app_generator import AdvancedChatAppGenerator -from flask import Flask, current_app -from pydantic import ValidationError -from sqlalchemy import select -from sqlalchemy.orm import Session, sessionmaker - -import contexts -from configs import dify_config -from constants import UUID_NIL - -if TYPE_CHECKING: - from controllers.console.app.workflow import LoopNodeRunPayload - from core.app.apps.workflow_app_runner import WorkflowRunDriver -from core.app.app_config.features.file_upload.manager import FileUploadConfigManager -from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager -from core.app.apps.advanced_chat.app_runner import AdvancedChatAppRunner -from core.app.apps.advanced_chat.generate_response_converter import AdvancedChatAppGenerateResponseConverter -from core.app.apps.advanced_chat.generate_task_pipeline import ( - AdvancedChatAppGenerateTaskPipeline, - ConversationSnapshot, - MessageSnapshot, - WorkflowSnapshot, -) -from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom -from core.app.apps.draft_variable_saver import DraftVariableSaverFactory -from core.app.apps.exc import GenerateTaskStoppedError -from core.app.apps.message_based_app_generator import MessageBasedAppGenerator -from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager -from core.app.apps.workflow.active_workflow_tasks import active_workflow_task -from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom -from core.app.entities.task_entities import ( - AdvancedChatPausedBlockingResponse, - ChatbotAppBlockingResponse, - ChatbotAppStreamResponse, -) -from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig -from core.helper.trace_id_helper import extract_external_trace_id_from_args, extract_trace_session_id_from_args -from core.ops.ops_trace_manager import TraceQueueManager -from core.prompt.utils.get_thread_messages_length import get_thread_messages_length -from core.repositories import DifyCoreRepositoryFactory -from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository -from extensions.ext_database import db -from factories import file_factory -from graphon.engine.filter import ResponseStreamFilter -from graphon.model_runtime.errors.invoke import InvokeAuthorizationError -from graphon.runtime import RuntimeState -from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader -from libs.flask_utils import preserve_flask_contexts -from models import Account, App, Conversation, EndUser, Message, Workflow, WorkflowNodeExecutionTriggeredFrom -from models.enums import WorkflowRunTriggeredFrom -from services.conversation_service import ConversationService -from services.errors.conversation import ConversationNotExistsError -from services.workflow_draft_variable_service import ( - DraftVarLoader, - WorkflowDraftVariableService, -) - -logger = logging.getLogger(__name__) - - -def _extract_trace_session_id_from_debug_args(args: Mapping[str, Any] | Any) -> dict[str, str]: - if isinstance(args, Mapping): - return extract_trace_session_id_from_args(args) - return extract_trace_session_id_from_args({"trace_session_id": getattr(args, "trace_session_id", None)}) - - -class AdvancedChatAppGenerator(MessageBasedAppGenerator): - def __init__(self, *, execution_driver: WorkflowRunDriver) -> None: - self._execution_driver = execution_driver - - _dialogue_count: int - - @overload - def generate( - self, - app_model: App, - workflow: Workflow, - user: Account | EndUser, - args: Mapping[str, Any], - invoke_from: InvokeFrom, - workflow_run_id: str, - streaming: Literal[False], - pause_state_config: PauseStateLayerConfig | None = None, - *, - session: Session, - ) -> Mapping[str, Any]: ... - - @overload - def generate( - self, - app_model: App, - workflow: Workflow, - user: Account | EndUser, - args: Mapping[str, Any], - invoke_from: InvokeFrom, - workflow_run_id: str, - streaming: Literal[True], - pause_state_config: PauseStateLayerConfig | None = None, - *, - session: Session, - ) -> Generator[Mapping | str]: ... - - @overload - def generate( - self, - app_model: App, - workflow: Workflow, - user: Account | EndUser, - args: Mapping[str, Any], - invoke_from: InvokeFrom, - workflow_run_id: str, - streaming: bool, - pause_state_config: PauseStateLayerConfig | None = None, - *, - session: Session, - ) -> Mapping[str, Any] | Generator[str | Mapping]: ... - - def generate( - self, - app_model: App, - workflow: Workflow, - user: Account | EndUser, - args: Mapping[str, Any], - invoke_from: InvokeFrom, - workflow_run_id: str, - streaming: bool = True, - pause_state_config: PauseStateLayerConfig | None = None, - *, - session: Session, - ) -> Mapping[str, Any] | Generator[str | Mapping]: - """ - Generate App response. - - :param app_model: App - :param workflow: Workflow - :param user: account or end user - :param args: request args - :param invoke_from: invoke from source - :param streaming: is stream - :param session: database session supplied by the caller - """ - if not args.get("query"): - raise ValueError("query is required") - - query = args["query"] - if not isinstance(query, str): - raise ValueError("query must be a string") - - query = query.replace("\x00", "") - inputs = args["inputs"] - - extras = { - "auto_generate_conversation_name": args.get("auto_generate_name", False), - **extract_external_trace_id_from_args(args), - **extract_trace_session_id_from_args(args), - } - - # get conversation - conversation = None - conversation_id = args.get("conversation_id") - if conversation_id: - try: - conversation = ConversationService.get_conversation( - app_model=app_model, conversation_id=conversation_id, user=user, session=session - ) - except ConversationNotExistsError: - if invoke_from == InvokeFrom.SERVICE_API: - conversation = None - else: - raise - - # parse files - # TODO(QuantumGhost): Move file parsing logic to the API controller layer - # for better separation of concerns. - # - # For implementation reference, see the `_parse_file` function and - # `DraftWorkflowNodeRunApi` class which handle this properly. - with self._bind_file_access_scope(tenant_id=app_model.tenant_id, user=user, invoke_from=invoke_from): - files = args["files"] if args.get("files") else [] - file_extra_config = FileUploadConfigManager.convert(workflow.features_dict, is_vision=False) - if file_extra_config: - file_objs = file_factory.build_from_mappings( - mappings=files, - tenant_id=app_model.tenant_id, - config=file_extra_config, - access_controller=self._file_access_controller, - ) - else: - file_objs = [] - - # convert to app config - app_config = AdvancedChatAppConfigManager.get_app_config(app_model=app_model, workflow=workflow) - - # get tracing instance - trace_manager = TraceQueueManager( - app_id=app_model.id, user_id=user.id if isinstance(user, Account) else user.session_id - ) - - if invoke_from == InvokeFrom.DEBUGGER: - # always enable retriever resource in debugger mode - app_config.additional_features.show_retrieve_source = True # type: ignore - - # init application generate entity - application_generate_entity = AdvancedChatAppGenerateEntity( - task_id=str(uuid.uuid4()), - app_config=app_config, - file_upload_config=file_extra_config, - conversation_id=conversation.id if conversation else None, - inputs=self._prepare_user_inputs( - user_inputs=inputs, variables=app_config.variables, tenant_id=app_model.tenant_id - ), - query=query, - files=list(file_objs), - parent_message_id=( - args.get("parent_message_id") - if invoke_from not in {InvokeFrom.SERVICE_API, InvokeFrom.OPENAPI} - else UUID_NIL - ), - user_id=user.id, - stream=streaming, - invoke_from=invoke_from, - extras=extras, - trace_manager=trace_manager, - workflow_run_id=str(workflow_run_id), - ) - contexts.plugin_tool_providers.set({}) - contexts.plugin_tool_providers_lock.set(threading.Lock()) - - # Create repositories - # - # Create session factory - session_factory = sessionmaker(bind=db.engine, expire_on_commit=False) - # Create workflow execution(aka workflow run) repository - if invoke_from == InvokeFrom.DEBUGGER: - workflow_triggered_from = WorkflowRunTriggeredFrom.DEBUGGING - else: - workflow_triggered_from = WorkflowRunTriggeredFrom.APP_RUN - workflow_execution_repository = DifyCoreRepositoryFactory.create_workflow_execution_repository( - session_factory=session_factory, - tenant_id=app_model.tenant_id, - user=user, - app_id=application_generate_entity.app_config.app_id, - triggered_from=workflow_triggered_from, - ) - # Create workflow node execution repository - workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository( - session_factory=session_factory, - tenant_id=app_model.tenant_id, - user=user, - app_id=application_generate_entity.app_config.app_id, - triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, - ) - - return self._generate( - workflow=workflow, - user=user, - invoke_from=invoke_from, - application_generate_entity=application_generate_entity, - workflow_execution_repository=workflow_execution_repository, - workflow_node_execution_repository=workflow_node_execution_repository, - conversation=conversation, - stream=streaming, - pause_state_config=pause_state_config, - session=session, - ) - - def resume( - self, - *, - app_model: App, - workflow: Workflow, - user: Account | EndUser, - conversation: Conversation, - message: Message, - session: Session, - application_generate_entity: AdvancedChatAppGenerateEntity, - workflow_execution_repository: WorkflowExecutionRepository, - workflow_node_execution_repository: WorkflowNodeExecutionRepository, - graph_runtime_state: RuntimeState, - pause_state_config: PauseStateLayerConfig | None = None, - response_stream_filter: ResponseStreamFilter | None = None, - ): - """ - Resume a paused advanced chat execution. - - ``trace_manager`` is transient and excluded from generate-entity serialization, - so resumed executions rebuild it here before persistence layers receive the entity. - """ - if application_generate_entity.trace_manager is None: - application_generate_entity = application_generate_entity.model_copy( - update={ - "trace_manager": TraceQueueManager( - app_id=app_model.id, - user_id=user.id if isinstance(user, Account) else user.session_id, - ) - } - ) - - return self._generate( - workflow=workflow, - user=user, - invoke_from=application_generate_entity.invoke_from, - application_generate_entity=application_generate_entity, - workflow_execution_repository=workflow_execution_repository, - workflow_node_execution_repository=workflow_node_execution_repository, - conversation=conversation, - message=message, - stream=application_generate_entity.stream, - pause_state_config=pause_state_config, - graph_runtime_state=graph_runtime_state, - response_stream_filter=response_stream_filter, - session=session, - ) - - def single_iteration_generate( - self, - app_model: App, - workflow: Workflow, - node_id: str, - user: Account | EndUser, - args: Mapping[str, Any], - streaming: bool = True, - *, - session: Session, - ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any]]: - """ - Generate App response. - - :param app_model: App - :param workflow: Workflow - :param node_id: the node id - :param user: account or end user - :param args: request args - :param streaming: is streamed - :param session: database session supplied by the caller - """ - if not node_id: - raise ValueError("node_id is required") - - if args.get("inputs") is None: - raise ValueError("inputs is required") - - # convert to app config - app_config = AdvancedChatAppConfigManager.get_app_config(app_model=app_model, workflow=workflow) - - # init application generate entity - application_generate_entity = AdvancedChatAppGenerateEntity( - task_id=str(uuid.uuid4()), - app_config=app_config, - conversation_id=None, - inputs={}, - query="", - files=[], - user_id=user.id, - stream=streaming, - invoke_from=InvokeFrom.DEBUGGER, - extras={ - "auto_generate_conversation_name": False, - **_extract_trace_session_id_from_debug_args(args), - }, - single_iteration_run=AdvancedChatAppGenerateEntity.SingleIterationRunEntity( - node_id=node_id, inputs=args["inputs"] - ), - ) - contexts.plugin_tool_providers.set({}) - contexts.plugin_tool_providers_lock.set(threading.Lock()) - - # Create repositories - # - # Create session factory - session_factory = sessionmaker(bind=db.engine, expire_on_commit=False) - # Create workflow execution(aka workflow run) repository - workflow_execution_repository = DifyCoreRepositoryFactory.create_workflow_execution_repository( - session_factory=session_factory, - tenant_id=app_model.tenant_id, - user=user, - app_id=application_generate_entity.app_config.app_id, - triggered_from=WorkflowRunTriggeredFrom.DEBUGGING, - ) - # Create workflow node execution repository - workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository( - session_factory=session_factory, - tenant_id=app_model.tenant_id, - user=user, - app_id=application_generate_entity.app_config.app_id, - triggered_from=WorkflowNodeExecutionTriggeredFrom.SINGLE_STEP, - ) - var_loader = DraftVarLoader( - engine=db.engine, - app_id=application_generate_entity.app_config.app_id, - tenant_id=application_generate_entity.app_config.tenant_id, - user_id=user.id, - ) - draft_var_srv = WorkflowDraftVariableService(session) - draft_var_srv.prefill_conversation_variable_default_values(workflow, user_id=user.id) - - return self._generate( - workflow=workflow, - user=user, - invoke_from=InvokeFrom.DEBUGGER, - application_generate_entity=application_generate_entity, - workflow_execution_repository=workflow_execution_repository, - workflow_node_execution_repository=workflow_node_execution_repository, - conversation=None, - stream=streaming, - variable_loader=var_loader, - session=session, - ) - - def single_loop_generate( - self, - app_model: App, - workflow: Workflow, - node_id: str, - user: Account | EndUser, - args: LoopNodeRunPayload, - streaming: bool = True, - *, - session: Session, - ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any]]: - """ - Generate App response. - - :param app_model: App - :param workflow: Workflow - :param node_id: the node id - :param user: account or end user - :param args: request args - :param streaming: is stream - :param session: database session supplied by the caller - """ - if not node_id: - raise ValueError("node_id is required") - - if args.inputs is None: - raise ValueError("inputs is required") - - # convert to app config - app_config = AdvancedChatAppConfigManager.get_app_config(app_model=app_model, workflow=workflow) - - # init application generate entity - application_generate_entity = AdvancedChatAppGenerateEntity( - task_id=str(uuid.uuid4()), - app_config=app_config, - conversation_id=None, - inputs={}, - query="", - files=[], - user_id=user.id, - stream=streaming, - invoke_from=InvokeFrom.DEBUGGER, - extras={ - "auto_generate_conversation_name": False, - **_extract_trace_session_id_from_debug_args(args), - }, - single_loop_run=AdvancedChatAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args.inputs), - ) - contexts.plugin_tool_providers.set({}) - contexts.plugin_tool_providers_lock.set(threading.Lock()) - - # Create repositories - # - # Create session factory - session_factory = sessionmaker(bind=db.engine, expire_on_commit=False) - # Create workflow execution(aka workflow run) repository - workflow_execution_repository = DifyCoreRepositoryFactory.create_workflow_execution_repository( - session_factory=session_factory, - tenant_id=app_model.tenant_id, - user=user, - app_id=application_generate_entity.app_config.app_id, - triggered_from=WorkflowRunTriggeredFrom.DEBUGGING, - ) - # Create workflow node execution repository - workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository( - session_factory=session_factory, - tenant_id=app_model.tenant_id, - user=user, - app_id=application_generate_entity.app_config.app_id, - triggered_from=WorkflowNodeExecutionTriggeredFrom.SINGLE_STEP, - ) - var_loader = DraftVarLoader( - engine=db.engine, - app_id=application_generate_entity.app_config.app_id, - tenant_id=application_generate_entity.app_config.tenant_id, - user_id=user.id, - ) - draft_var_srv = WorkflowDraftVariableService(session) - draft_var_srv.prefill_conversation_variable_default_values(workflow, user_id=user.id) - - return self._generate( - workflow=workflow, - user=user, - invoke_from=InvokeFrom.DEBUGGER, - application_generate_entity=application_generate_entity, - workflow_execution_repository=workflow_execution_repository, - workflow_node_execution_repository=workflow_node_execution_repository, - conversation=None, - stream=streaming, - variable_loader=var_loader, - session=session, - ) - - def _generate( - self, - *, - workflow: Workflow, - user: Account | EndUser, - invoke_from: InvokeFrom, - application_generate_entity: AdvancedChatAppGenerateEntity, - session: Session, - workflow_execution_repository: WorkflowExecutionRepository, - workflow_node_execution_repository: WorkflowNodeExecutionRepository, - conversation: Conversation | None = None, - message: Message | None = None, - stream: bool = True, - variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER, - pause_state_config: PauseStateLayerConfig | None = None, - graph_runtime_state: RuntimeState | None = None, - response_stream_filter: ResponseStreamFilter | None = None, - ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any]]: - """ - Generate App response. - - :param workflow: Workflow - :param user: account or end user - :param invoke_from: invoke from source - :param application_generate_entity: application generate entity - :param session: database session supplied by the caller - :param workflow_execution_repository: repository for workflow execution - :param workflow_node_execution_repository: repository for workflow node execution - :param conversation: conversation - :param stream: is stream - """ - with self._bind_file_access_scope( - tenant_id=application_generate_entity.app_config.tenant_id, - user=user, - invoke_from=invoke_from, - ): - is_first_conversation = conversation is None - - if conversation is not None and message is not None: - pass - else: - conversation, message = self._init_generate_records( - application_generate_entity, - conversation, - session=session, - ) - - if is_first_conversation: - # update conversation features - conversation.override_model_configs = workflow.features - session.commit() - session.refresh(conversation) - - # get conversation dialogue count - # NOTE: dialogue_count should not start from 0, - # because during the first conversation, dialogue_count should be 1. - self._dialogue_count = get_thread_messages_length(conversation.id, session=session) + 1 - - # init queue manager - queue_manager = MessageBasedAppQueueManager( - task_id=application_generate_entity.task_id, - user_id=application_generate_entity.user_id, - invoke_from=application_generate_entity.invoke_from, - conversation_id=conversation.id, - app_mode=conversation.mode, - message_id=message.id, - ) - - # new thread with request context and contextvars - context = contextvars.copy_context() - - worker_thread = threading.Thread( - target=self._generate_worker, - kwargs={ - "flask_app": current_app._get_current_object(), # type: ignore - "application_generate_entity": application_generate_entity, - "queue_manager": queue_manager, - "conversation_id": conversation.id, - "message_id": message.id, - "context": context, - "variable_loader": variable_loader, - "workflow_execution_repository": workflow_execution_repository, - "workflow_node_execution_repository": workflow_node_execution_repository, - "pause_state_config": pause_state_config, - "graph_runtime_state": graph_runtime_state, - "response_stream_filter": response_stream_filter, - }, - ) - - worker_thread.start() - - # Capture the scalar fields needed by the response pipeline before - # releasing the request-scoped SQLAlchemy session. - workflow_snapshot = WorkflowSnapshot.from_workflow(workflow) - conversation_snapshot = ConversationSnapshot.from_conversation(conversation) - message_snapshot = MessageSnapshot.from_message(message) - session.close() - - try: - response = self._handle_advanced_chat_response( - application_generate_entity=application_generate_entity, - workflow=workflow_snapshot, - queue_manager=queue_manager, - conversation=conversation_snapshot, - message=message_snapshot, - user=user, - stream=stream, - draft_var_saver_factory=self._get_draft_var_saver_factory( - invoke_from, - account=user, - tenant_id=application_generate_entity.app_config.tenant_id, - ), - ) - converted_response = AdvancedChatAppGenerateResponseConverter.convert( - response=response, - invoke_from=invoke_from, - ) - except BaseException: - self._join_worker_thread(worker_thread) - raise - - if isinstance(converted_response, Generator): - return self._wrap_stream_with_worker_thread_join(converted_response, worker_thread) - - self._join_worker_thread(worker_thread) - return converted_response - - def _generate_worker( - self, - flask_app: Flask, - application_generate_entity: AdvancedChatAppGenerateEntity, - queue_manager: AppQueueManager, - conversation_id: str, - message_id: str, - context: contextvars.Context, - variable_loader: VariableLoader, - workflow_execution_repository: WorkflowExecutionRepository, - workflow_node_execution_repository: WorkflowNodeExecutionRepository, - graph_runtime_state: RuntimeState | None = None, - response_stream_filter: ResponseStreamFilter | None = None, - pause_state_config: PauseStateLayerConfig | None = None, - ): - """ - Generate worker in a new thread. - :param flask_app: Flask app - :param application_generate_entity: application generate entity - :param queue_manager: queue manager - :param conversation_id: conversation ID - :param message_id: message ID - :return: - """ - - with preserve_flask_contexts(flask_app, context_vars=context): - # get conversation and message - conversation = self._get_conversation(conversation_id) - message = self._get_message(message_id) - - with Session(db.engine, expire_on_commit=False) as session: - workflow = session.scalar( - select(Workflow).where( - Workflow.tenant_id == application_generate_entity.app_config.tenant_id, - Workflow.app_id == application_generate_entity.app_config.app_id, - Workflow.id == application_generate_entity.app_config.workflow_id, - ) - ) - if workflow is None: - raise ValueError("Workflow not found") - if graph_runtime_state is not None: - self._restore_workflow_run_graph( - session=session, - workflow=workflow, - workflow_run_id=application_generate_entity.workflow_run_id, - ) - - # Determine system_user_id based on invocation source - is_external_api_call = application_generate_entity.invoke_from in { - InvokeFrom.WEB_APP, - InvokeFrom.SERVICE_API, - } - - if is_external_api_call: - # For external API calls, use end user's session ID - end_user = session.scalar(select(EndUser).where(EndUser.id == application_generate_entity.user_id)) - system_user_id = end_user.session_id if end_user else "" - else: - # For internal calls, use the original user ID - system_user_id = application_generate_entity.user_id - - app = session.scalar(select(App).where(App.id == application_generate_entity.app_config.app_id)) - if app is None: - raise ValueError("App not found") - - runner = AdvancedChatAppRunner( - application_generate_entity=application_generate_entity, - execution_driver=self._execution_driver, - queue_manager=queue_manager, - conversation=conversation, - message=message, - dialogue_count=self._dialogue_count, - variable_loader=variable_loader, - workflow=workflow, - system_user_id=system_user_id, - app=app, - graph_runtime_state=graph_runtime_state, - response_stream_filter=response_stream_filter, - ) - - try: - with active_workflow_task(application_generate_entity.task_id): - self._execution_driver( - runner, pause_state_config, workflow_execution_repository, workflow_node_execution_repository - ) - except GenerateTaskStoppedError: - pass - except InvokeAuthorizationError: - queue_manager.publish_error( - InvokeAuthorizationError("Incorrect API key provided"), PublishFrom.APPLICATION_MANAGER - ) - except ValidationError as e: - logger.exception("Validation Error when generating") - queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER) - except ValueError as e: - if dify_config.DEBUG: - logger.exception("Error when generating") - queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER) - except Exception as e: - logger.exception("Unknown Error when generating") - queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER) - finally: - db.session.close() - - def _handle_advanced_chat_response( - self, - *, - application_generate_entity: AdvancedChatAppGenerateEntity, - workflow: WorkflowSnapshot, - queue_manager: AppQueueManager, - conversation: ConversationSnapshot, - message: MessageSnapshot, - user: Account | EndUser, - draft_var_saver_factory: DraftVariableSaverFactory, - stream: bool = False, - ) -> ChatbotAppBlockingResponse | AdvancedChatPausedBlockingResponse | Generator[ChatbotAppStreamResponse]: - """ - Handle response. - :param application_generate_entity: application generate entity - :param workflow: workflow - :param queue_manager: queue manager - :param conversation: conversation - :param message: message - :param user: account or end user - :param stream: is stream - :return: - """ - # init generate task pipeline - generate_task_pipeline = AdvancedChatAppGenerateTaskPipeline( - application_generate_entity=application_generate_entity, - workflow=workflow, - queue_manager=queue_manager, - conversation=conversation, - message=message, - user=user, - dialogue_count=self._dialogue_count, - stream=stream, - draft_var_saver_factory=draft_var_saver_factory, - ) - - try: - return generate_task_pipeline.process() - except ValueError as e: - if len(e.args) > 0 and e.args[0] == "I/O operation on closed file.": # ignore this error - raise GenerateTaskStoppedError() from e - else: - logger.exception("Failed to process generate task pipeline, conversation_id: %s", conversation.id) - raise e +__all__ = ["AdvancedChatAppGenerator"] diff --git a/api/core/app/apps/pipeline/pipeline_generator.py b/api/core/app/apps/pipeline/pipeline_generator.py index 58f86248ea37fa..9e174d01ae028b 100644 --- a/api/core/app/apps/pipeline/pipeline_generator.py +++ b/api/core/app/apps/pipeline/pipeline_generator.py @@ -1,909 +1,5 @@ -import contextvars -import json -import logging -import secrets -import threading -import time -import uuid -from collections.abc import Generator, Mapping -from typing import TYPE_CHECKING, Any, Literal, cast, overload +"""Compatibility import for the pipeline generation service adapter.""" -from flask import Flask, current_app -from pydantic import ValidationError -from sqlalchemy import select -from sqlalchemy.orm import Session, sessionmaker +from services.workflow.execution.adapters.pipeline.pipeline_generator import PipelineGenerator -import contexts -from configs import dify_config -from core.app.apps.base_app_generator import BaseAppGenerator -from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom -from core.app.apps.draft_variable_saver import DraftVariableSaverFactory -from core.app.apps.exc import GenerateTaskStoppedError -from core.app.apps.pipeline.pipeline_config_manager import PipelineConfigManager -from core.app.apps.pipeline.pipeline_queue_manager import PipelineQueueManager -from core.app.apps.pipeline.pipeline_runner import PipelineDocumentStore, PipelineRunner -from core.app.apps.workflow.generate_response_converter import WorkflowAppGenerateResponseConverter -from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline -from core.app.entities.app_invoke_entities import InvokeFrom, RagPipelineGenerateEntity -from core.app.entities.rag_pipeline_invoke_entities import RagPipelineInvokeEntity -from core.app.entities.task_entities import ( - WorkflowAppBlockingResponse, - WorkflowAppPausedBlockingResponse, - WorkflowAppStreamResponse, -) -from core.datasource.entities.datasource_entities import ( - DatasourceProviderType, - OnlineDriveBrowseFilesRequest, -) -from core.datasource.online_drive.online_drive_plugin import OnlineDriveDatasourcePlugin -from core.entities.knowledge_entities import PipelineDataset, PipelineDocument -from core.rag.index_processor.constant.index_type import IndexStructureType -from core.repositories.factory import ( - DifyCoreRepositoryFactory, - WorkflowExecutionRepository, - WorkflowNodeExecutionRepository, -) -from extensions.ext_database import db -from graphon.model_runtime.errors.invoke import InvokeAuthorizationError -from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader -from libs.datetime_utils import naive_utc_now -from libs.flask_utils import preserve_flask_contexts -from models import Account, EndUser, Workflow, WorkflowNodeExecutionTriggeredFrom -from models.dataset import Document, DocumentPipelineExecutionLog, Pipeline, build_built_in_field_values -from models.enums import DataSourceType, DocumentCreatedFrom, WorkflowRunTriggeredFrom -from models.model import AppMode -from services.data_source.provider_service import DatasourceProviderService -from services.rag_pipeline.rag_pipeline_task_proxy import RagPipelineTaskProxy -from services.workflow_draft_variable_service import DraftVarLoader, WorkflowDraftVariableService - -if TYPE_CHECKING: - from core.app.apps.workflow_app_runner import WorkflowRunDriver - -logger = logging.getLogger(__name__) - - -class PipelineGenerator(BaseAppGenerator): - def __init__( - self, - *, - execution_driver: "WorkflowRunDriver", - documents: PipelineDocumentStore, - datasource_providers: DatasourceProviderService, - ) -> None: - self._execution_driver = execution_driver - self._documents = documents - self._datasource_providers = datasource_providers - - @overload - def generate( - self, - *, - session: Session, - pipeline: Pipeline, - workflow: Workflow, - user: Account | EndUser, - args: Mapping[str, Any], - invoke_from: InvokeFrom, - streaming: Literal[True], - call_depth: int, - workflow_thread_pool_id: str | None, - is_retry: bool = False, - ) -> Generator[Mapping | str]: ... - - @overload - def generate( - self, - *, - session: Session, - pipeline: Pipeline, - workflow: Workflow, - user: Account | EndUser, - args: Mapping[str, Any], - invoke_from: InvokeFrom, - streaming: Literal[False], - call_depth: int, - workflow_thread_pool_id: str | None, - is_retry: bool = False, - ) -> Mapping[str, Any]: ... - - @overload - def generate( - self, - *, - session: Session, - pipeline: Pipeline, - workflow: Workflow, - user: Account | EndUser, - args: Mapping[str, Any], - invoke_from: InvokeFrom, - streaming: bool, - call_depth: int, - workflow_thread_pool_id: str | None, - is_retry: bool = False, - ) -> Mapping[str, Any] | Generator[Mapping | str]: ... - - def generate( - self, - *, - session: Session, - pipeline: Pipeline, - workflow: Workflow, - user: Account | EndUser, - args: Mapping[str, Any], - invoke_from: InvokeFrom, - streaming: bool = True, - call_depth: int = 0, - workflow_thread_pool_id: str | None = None, - is_retry: bool = False, - ) -> Mapping[str, Any] | Generator[Mapping | str] | None: - # Add null check for dataset - - dataset = self._documents.get_pipeline_dataset(pipeline, session=session) - if not dataset: - raise ValueError("Pipeline dataset is required") - inputs: Mapping[str, Any] = args["inputs"] - start_node_id: str = args["start_node_id"] - datasource_type = DatasourceProviderType(args["datasource_type"]) - datasource_info_list: list[Mapping[str, Any]] = self._format_datasource_info_list( - datasource_type, args["datasource_info_list"], pipeline, workflow, start_node_id, user - ) - batch = time.strftime("%Y%m%d%H%M%S") + str(secrets.randbelow(900000) + 100000) - # convert to app config - pipeline_config = PipelineConfigManager.get_pipeline_config( - pipeline=pipeline, workflow=workflow, start_node_id=start_node_id - ) - documents: list[Document] = [] - if invoke_from == InvokeFrom.PUBLISHED_PIPELINE and not is_retry and not args.get("original_document_id"): - from services.feature_service import FeatureService - from services.knowledge.dataset_service import DocumentService - - features = FeatureService.get_features(pipeline.tenant_id) - DocumentService.check_document_creation_limits(len(datasource_info_list), features) - - for datasource_info in datasource_info_list: - position = DocumentService.get_documents_position(dataset.id, session) - document_created_from: DocumentCreatedFrom = DocumentCreatedFrom("rag-pipeline") - document = self._build_document( - tenant_id=pipeline.tenant_id, - dataset_id=dataset.id, - built_in_field_enabled=dataset.built_in_field_enabled, - datasource_type=datasource_type, - datasource_info=datasource_info, - created_from=document_created_from, - position=position, - account=user, - batch=batch, - document_form=dataset.chunk_structure, - ) - session.add(document) - documents.append(document) - session.flush() - - # run in child thread - rag_pipeline_invoke_entities = [] - for i, datasource_info in enumerate(datasource_info_list): - workflow_run_id = str(uuid.uuid4()) - document_id = args.get("original_document_id") or None - if invoke_from == InvokeFrom.PUBLISHED_PIPELINE and not is_retry: - document_id = document_id or documents[i].id - document_pipeline_execution_log = DocumentPipelineExecutionLog( - document_id=document_id, - datasource_type=datasource_type, - datasource_info=json.dumps(datasource_info), - datasource_node_id=start_node_id, - input_data=dict(inputs), - pipeline_id=pipeline.id, - created_by=user.id, - ) - session.add(document_pipeline_execution_log) - application_generate_entity = RagPipelineGenerateEntity( - task_id=str(uuid.uuid4()), - app_config=pipeline_config, - pipeline_config=pipeline_config, - datasource_type=datasource_type, - datasource_info=datasource_info, - dataset_id=dataset.id, - original_document_id=None if is_retry else args.get("original_document_id"), - start_node_id=start_node_id, - batch=batch, - document_id=document_id, - inputs=self._prepare_user_inputs( - user_inputs=inputs, - variables=pipeline_config.rag_pipeline_variables, - tenant_id=pipeline.tenant_id, - strict_type_validation=invoke_from == InvokeFrom.SERVICE_API, - ), - files=[], - user_id=user.id, - stream=streaming, - invoke_from=invoke_from, - call_depth=call_depth, - workflow_execution_id=workflow_run_id, - ) - - contexts.plugin_tool_providers.set({}) - contexts.plugin_tool_providers_lock.set(threading.Lock()) - if invoke_from == InvokeFrom.DEBUGGER: - workflow_triggered_from = WorkflowRunTriggeredFrom.RAG_PIPELINE_DEBUGGING - else: - workflow_triggered_from = WorkflowRunTriggeredFrom.RAG_PIPELINE_RUN - # Create workflow node execution repository - session_factory = sessionmaker(bind=db.engine, expire_on_commit=False) - workflow_execution_repository = DifyCoreRepositoryFactory.create_workflow_execution_repository( - session_factory=session_factory, - tenant_id=pipeline.tenant_id, - user=user, - app_id=application_generate_entity.app_config.app_id, - triggered_from=workflow_triggered_from, - ) - - workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository( - session_factory=session_factory, - tenant_id=pipeline.tenant_id, - user=user, - app_id=application_generate_entity.app_config.app_id, - triggered_from=WorkflowNodeExecutionTriggeredFrom.RAG_PIPELINE_RUN, - ) - if invoke_from == InvokeFrom.DEBUGGER or is_retry: - return self._generate( - session=session, - flask_app=current_app._get_current_object(), # type: ignore - context=contextvars.copy_context(), - pipeline=pipeline, - workflow_id=workflow.id, - user=user, - application_generate_entity=application_generate_entity, - invoke_from=invoke_from, - workflow_execution_repository=workflow_execution_repository, - workflow_node_execution_repository=workflow_node_execution_repository, - streaming=streaming, - workflow_thread_pool_id=workflow_thread_pool_id, - ) - else: - rag_pipeline_invoke_entities.append( - RagPipelineInvokeEntity( - pipeline_id=pipeline.id, - user_id=user.id, - tenant_id=pipeline.tenant_id, - workflow_id=workflow.id, - streaming=streaming, - workflow_execution_id=workflow_run_id, - workflow_thread_pool_id=workflow_thread_pool_id, - application_generate_entity=application_generate_entity.model_dump(), - ) - ) - - if invoke_from == InvokeFrom.PUBLISHED_PIPELINE and not is_retry: - session.commit() - if rag_pipeline_invoke_entities: - RagPipelineTaskProxy(dataset.tenant_id, user.id, rag_pipeline_invoke_entities).delay() - # return batch, dataset, documents - return { - "batch": batch, - "dataset": PipelineDataset( - id=dataset.id, - name=dataset.name, - description=dataset.description, - chunk_structure=dataset.chunk_structure, - ).model_dump(), - "documents": [ - PipelineDocument( - id=document.id, - position=document.position, - data_source_type=document.data_source_type, - data_source_info=json.loads(document.data_source_info) if document.data_source_info else None, - name=document.name, - indexing_status=document.indexing_status, - error=document.error, - enabled=document.enabled, - ).model_dump() - for document in documents - ], - } - - def _generate( - self, - *, - session: Session, - flask_app: Flask, - context: contextvars.Context, - pipeline: Pipeline, - workflow_id: str, - user: Account | EndUser, - application_generate_entity: RagPipelineGenerateEntity, - invoke_from: InvokeFrom, - workflow_execution_repository: WorkflowExecutionRepository, - workflow_node_execution_repository: WorkflowNodeExecutionRepository, - streaming: bool = True, - variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER, - workflow_thread_pool_id: str | None = None, - ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any]]: - """ - Generate App response. - - :param pipeline: Pipeline - :param workflow: Workflow - :param user: account or end user - :param application_generate_entity: application generate entity - :param invoke_from: invoke from source - :param workflow_execution_repository: repository for workflow execution - :param workflow_node_execution_repository: repository for workflow node execution - :param streaming: is stream - :param workflow_thread_pool_id: workflow thread pool id - """ - with preserve_flask_contexts(flask_app, context_vars=context): - # init queue manager - workflow = session.get(Workflow, workflow_id) - if not workflow: - raise ValueError(f"Workflow not found: {workflow_id}") - queue_manager = PipelineQueueManager( - task_id=application_generate_entity.task_id, - user_id=application_generate_entity.user_id, - invoke_from=application_generate_entity.invoke_from, - app_mode=AppMode.RAG_PIPELINE, - ) - context = contextvars.copy_context() - - # new thread - worker_thread = threading.Thread( - target=self._generate_worker, - kwargs={ - "flask_app": current_app._get_current_object(), # type: ignore - "context": context, - "queue_manager": queue_manager, - "application_generate_entity": application_generate_entity, - "workflow_thread_pool_id": workflow_thread_pool_id, - "variable_loader": variable_loader, - "workflow_execution_repository": workflow_execution_repository, - "workflow_node_execution_repository": workflow_node_execution_repository, - }, - ) - - worker_thread.start() - - draft_var_saver_factory = self._get_draft_var_saver_factory( - invoke_from, - user, - tenant_id=pipeline.tenant_id, - ) - try: - response = self._handle_response( - application_generate_entity=application_generate_entity, - workflow=workflow, - queue_manager=queue_manager, - user=user, - stream=streaming, - draft_var_saver_factory=draft_var_saver_factory, - ) - converted_response = WorkflowAppGenerateResponseConverter.convert( - response=response, - invoke_from=invoke_from, - ) - except BaseException: - self._join_worker_thread(worker_thread) - raise - - if isinstance(converted_response, Generator): - return self._wrap_stream_with_worker_thread_join(converted_response, worker_thread) - - self._join_worker_thread(worker_thread) - return converted_response - - def single_iteration_generate( - self, - pipeline: Pipeline, - workflow: Workflow, - node_id: str, - user: Account | EndUser, - args: Mapping[str, Any], - streaming: bool = True, - *, - session: Session, - ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any]]: - """ - Generate App response. - - :param pipeline: Pipeline - :param workflow: Workflow - :param node_id: the node id - :param user: account or end user - :param args: request args - :param streaming: is streamed - :param session: database session supplied by the caller - """ - if not node_id: - raise ValueError("node_id is required") - - if args.get("inputs") is None: - raise ValueError("inputs is required") - - # convert to app config - pipeline_config = PipelineConfigManager.get_pipeline_config( - pipeline=pipeline, workflow=workflow, start_node_id=args.get("start_node_id", "shared") - ) - - dataset = self._documents.get_pipeline_dataset(pipeline, session=session) - if not dataset: - raise ValueError("Pipeline dataset is required") - - # init application generate entity - use RagPipelineGenerateEntity instead - application_generate_entity = RagPipelineGenerateEntity( - task_id=str(uuid.uuid4()), - app_config=pipeline_config, - pipeline_config=pipeline_config, - datasource_type=args.get("datasource_type", ""), - datasource_info=args.get("datasource_info", {}), - dataset_id=dataset.id, - batch=args.get("batch", ""), - document_id=args.get("document_id"), - inputs={}, - files=[], - user_id=user.id, - stream=streaming, - invoke_from=InvokeFrom.DEBUGGER, - call_depth=0, - workflow_execution_id=str(uuid.uuid4()), - single_iteration_run=RagPipelineGenerateEntity.SingleIterationRunEntity( - node_id=node_id, inputs=args["inputs"] - ), - ) - contexts.plugin_tool_providers.set({}) - contexts.plugin_tool_providers_lock.set(threading.Lock()) - # Create workflow node execution repository - session_factory = sessionmaker(bind=db.engine, expire_on_commit=False) - - workflow_execution_repository = DifyCoreRepositoryFactory.create_workflow_execution_repository( - session_factory=session_factory, - tenant_id=pipeline.tenant_id, - user=user, - app_id=application_generate_entity.app_config.app_id, - triggered_from=WorkflowRunTriggeredFrom.RAG_PIPELINE_DEBUGGING, - ) - - workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository( - session_factory=session_factory, - tenant_id=pipeline.tenant_id, - user=user, - app_id=application_generate_entity.app_config.app_id, - triggered_from=WorkflowNodeExecutionTriggeredFrom.SINGLE_STEP, - ) - draft_var_srv = WorkflowDraftVariableService(session) - draft_var_srv.prefill_conversation_variable_default_values(workflow, user_id=user.id) - var_loader = DraftVarLoader( - engine=db.engine, - app_id=application_generate_entity.app_config.app_id, - tenant_id=application_generate_entity.app_config.tenant_id, - user_id=user.id, - ) - - return self._generate( - session=session, - flask_app=current_app._get_current_object(), # type: ignore - pipeline=pipeline, - workflow_id=workflow.id, - user=user, - invoke_from=InvokeFrom.DEBUGGER, - application_generate_entity=application_generate_entity, - workflow_execution_repository=workflow_execution_repository, - workflow_node_execution_repository=workflow_node_execution_repository, - streaming=streaming, - variable_loader=var_loader, - context=contextvars.copy_context(), - ) - - def single_loop_generate( - self, - pipeline: Pipeline, - workflow: Workflow, - node_id: str, - user: Account | EndUser, - args: Mapping[str, Any], - streaming: bool = True, - *, - session: Session, - ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any]]: - """ - Generate App response. - - :param pipeline: Pipeline - :param workflow: Workflow - :param node_id: the node id - :param user: account or end user - :param args: request args - :param streaming: is streamed - :param session: database session supplied by the caller - """ - if not node_id: - raise ValueError("node_id is required") - - if args.get("inputs") is None: - raise ValueError("inputs is required") - - dataset = self._documents.get_pipeline_dataset(pipeline, session=session) - if not dataset: - raise ValueError("Pipeline dataset is required") - - # convert to app config - pipeline_config = PipelineConfigManager.get_pipeline_config( - pipeline=pipeline, workflow=workflow, start_node_id=args.get("start_node_id", "shared") - ) - - # init application generate entity - application_generate_entity = RagPipelineGenerateEntity( - task_id=str(uuid.uuid4()), - app_config=pipeline_config, - pipeline_config=pipeline_config, - datasource_type=args.get("datasource_type", ""), - datasource_info=args.get("datasource_info", {}), - batch=args.get("batch", ""), - document_id=args.get("document_id"), - dataset_id=dataset.id, - inputs={}, - files=[], - user_id=user.id, - stream=streaming, - invoke_from=InvokeFrom.DEBUGGER, - extras={"auto_generate_conversation_name": False}, - single_loop_run=RagPipelineGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args["inputs"]), - workflow_execution_id=str(uuid.uuid4()), - ) - contexts.plugin_tool_providers.set({}) - contexts.plugin_tool_providers_lock.set(threading.Lock()) - - # Create workflow node execution repository - session_factory = sessionmaker(bind=db.engine, expire_on_commit=False) - - workflow_execution_repository = DifyCoreRepositoryFactory.create_workflow_execution_repository( - session_factory=session_factory, - tenant_id=pipeline.tenant_id, - user=user, - app_id=application_generate_entity.app_config.app_id, - triggered_from=WorkflowRunTriggeredFrom.RAG_PIPELINE_DEBUGGING, - ) - - workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository( - session_factory=session_factory, - tenant_id=pipeline.tenant_id, - user=user, - app_id=application_generate_entity.app_config.app_id, - triggered_from=WorkflowNodeExecutionTriggeredFrom.SINGLE_STEP, - ) - draft_var_srv = WorkflowDraftVariableService(session) - draft_var_srv.prefill_conversation_variable_default_values(workflow, user_id=user.id) - var_loader = DraftVarLoader( - engine=db.engine, - app_id=application_generate_entity.app_config.app_id, - tenant_id=application_generate_entity.app_config.tenant_id, - user_id=user.id, - ) - - return self._generate( - session=session, - flask_app=current_app._get_current_object(), # type: ignore - pipeline=pipeline, - workflow_id=workflow.id, - user=user, - invoke_from=InvokeFrom.DEBUGGER, - application_generate_entity=application_generate_entity, - workflow_execution_repository=workflow_execution_repository, - workflow_node_execution_repository=workflow_node_execution_repository, - streaming=streaming, - variable_loader=var_loader, - context=contextvars.copy_context(), - ) - - def _generate_worker( - self, - flask_app: Flask, - application_generate_entity: RagPipelineGenerateEntity, - queue_manager: AppQueueManager, - context: contextvars.Context, - variable_loader: VariableLoader, - workflow_execution_repository: WorkflowExecutionRepository, - workflow_node_execution_repository: WorkflowNodeExecutionRepository, - workflow_thread_pool_id: str | None = None, - ) -> None: - """ - Generate worker in a new thread. - :param flask_app: Flask app - :param application_generate_entity: application generate entity - :param queue_manager: queue manager - :param workflow_thread_pool_id: workflow thread pool id - :return: - """ - - with preserve_flask_contexts(flask_app, context_vars=context): - try: - with Session(db.engine, expire_on_commit=False) as session: - workflow = session.scalar( - select(Workflow).where( - Workflow.tenant_id == application_generate_entity.app_config.tenant_id, - Workflow.app_id == application_generate_entity.app_config.app_id, - Workflow.id == application_generate_entity.app_config.workflow_id, - ) - ) - if workflow is None: - raise ValueError("Workflow not found") - - # Determine system_user_id based on invocation source - is_external_api_call = application_generate_entity.invoke_from in { - InvokeFrom.WEB_APP, - InvokeFrom.SERVICE_API, - } - - if is_external_api_call: - # For external API calls, use end user's session ID - end_user = session.scalar( - select(EndUser).where(EndUser.id == application_generate_entity.user_id) - ) - system_user_id = end_user.session_id if end_user else "" - else: - # For internal calls, use the original user ID - system_user_id = application_generate_entity.user_id - # workflow app - runner = PipelineRunner( - execution_driver=self._execution_driver, - application_generate_entity=application_generate_entity, - queue_manager=queue_manager, - workflow_thread_pool_id=workflow_thread_pool_id, - variable_loader=variable_loader, - workflow=workflow, - system_user_id=system_user_id, - documents=self._documents, - ) - - self._execution_driver( - runner, None, workflow_execution_repository, workflow_node_execution_repository - ) - except GenerateTaskStoppedError: - pass - except InvokeAuthorizationError: - queue_manager.publish_error( - InvokeAuthorizationError("Incorrect API key provided"), PublishFrom.APPLICATION_MANAGER - ) - except ValidationError as e: - logger.exception("Validation Error when generating") - queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER) - except ValueError as e: - if dify_config.DEBUG: - logger.exception("Error when generating") - queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER) - except Exception as e: - logger.exception("Unknown Error when generating") - queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER) - - def _handle_response( - self, - application_generate_entity: RagPipelineGenerateEntity, - workflow: Workflow, - queue_manager: AppQueueManager, - user: Account | EndUser, - draft_var_saver_factory: DraftVariableSaverFactory, - stream: bool = False, - ) -> WorkflowAppBlockingResponse | WorkflowAppPausedBlockingResponse | Generator[WorkflowAppStreamResponse]: - """ - Handle response. - :param application_generate_entity: application generate entity - :param workflow: workflow - :param queue_manager: queue manager - :param user: account or end user - :param stream: is stream - :return: - """ - # init generate task pipeline - generate_task_pipeline = WorkflowAppGenerateTaskPipeline( - application_generate_entity=application_generate_entity, - workflow=workflow, - queue_manager=queue_manager, - user=user, - stream=stream, - draft_var_saver_factory=draft_var_saver_factory, - ) - - try: - return generate_task_pipeline.process() - except ValueError as e: - if len(e.args) > 0 and e.args[0] == "I/O operation on closed file.": # ignore this error - raise GenerateTaskStoppedError() from e - else: - logger.exception( - "Fails to process generate task pipeline, task_id: %r", - application_generate_entity.task_id, - ) - raise e - - def _build_document( - self, - tenant_id: str, - dataset_id: str, - built_in_field_enabled: bool, - datasource_type: DatasourceProviderType, - datasource_info: Mapping[str, Any], - created_from: DocumentCreatedFrom, - position: int, - account: Account | EndUser, - batch: str, - document_form: IndexStructureType, - ): - match datasource_type: - case DatasourceProviderType.LOCAL_FILE: - name = datasource_info.get("name", "untitled") - case DatasourceProviderType.ONLINE_DOCUMENT: - name = datasource_info.get("page", {}).get("page_name", "untitled") - case DatasourceProviderType.WEBSITE_CRAWL: - name = datasource_info.get("title", "untitled") - case DatasourceProviderType.ONLINE_DRIVE: - name = datasource_info.get("name", "untitled") - case _: - raise ValueError(f"Unsupported datasource type: {datasource_type}") - document_data_source_type: DataSourceType = DataSourceType(datasource_type) - document = Document( - tenant_id=tenant_id, - dataset_id=dataset_id, - position=position, - data_source_type=document_data_source_type, - data_source_info=json.dumps(datasource_info), - batch=batch, - name=name, - created_from=created_from, - created_by=account.id, - doc_form=document_form, - ) - if built_in_field_enabled: - now = naive_utc_now() - document.doc_metadata = build_built_in_field_values( - document_name=name, - uploader=account.name, - upload_date=now, - last_update_date=now, - data_source_type=datasource_type, - ) - return document - - def _format_datasource_info_list( - self, - datasource_type: DatasourceProviderType, - datasource_info_list: list[Mapping[str, Any]], - pipeline: Pipeline, - workflow: Workflow, - start_node_id: str, - user: Account | EndUser, - ) -> list[Mapping[str, Any]]: - """ - Format datasource info list. - """ - if datasource_type == DatasourceProviderType.ONLINE_DRIVE: - all_files: list[Mapping[str, Any]] = [] - datasource_node_data = None - datasource_nodes = workflow.graph_dict.get("nodes", []) - for datasource_node in datasource_nodes: - if datasource_node.get("id") == start_node_id: - datasource_node_data = datasource_node.get("data", {}) - break - if not datasource_node_data: - raise ValueError("Datasource node data not found") - - from core.datasource.datasource_manager import DatasourceManager - - datasource_runtime = DatasourceManager.get_datasource_runtime( - provider_id=f"{datasource_node_data.get('plugin_id')}/{datasource_node_data.get('provider_name')}", - datasource_name=datasource_node_data.get("datasource_name"), - tenant_id=pipeline.tenant_id, - datasource_type=DatasourceProviderType(datasource_type), - ) - credentials = self._datasource_providers.get_datasource_credentials( - tenant_id=pipeline.tenant_id, - provider=datasource_node_data.get("provider_name"), - plugin_id=datasource_node_data.get("plugin_id"), - credential_id=datasource_node_data.get("credential_id"), - ) - if credentials: - datasource_runtime.runtime.credentials = credentials - datasource_runtime = cast(OnlineDriveDatasourcePlugin, datasource_runtime) - - for datasource_info in datasource_info_list: - if datasource_info.get("id") and datasource_info.get("type") == "folder": - # get all files in the folder - self._get_files_in_folder( - datasource_runtime, - datasource_info.get("id", ""), - datasource_info.get("bucket", None), - user.id, - all_files, - datasource_info, - None, - ) - else: - all_files.append( - { - "id": datasource_info.get("id", ""), - "name": datasource_info.get("name", "untitled"), - "bucket": datasource_info.get("bucket", None), - } - ) - return all_files - else: - return datasource_info_list - - def _get_files_in_folder( - self, - datasource_runtime: OnlineDriveDatasourcePlugin, - prefix: str, - bucket: str | None, - user_id: str, - all_files: list, - datasource_info: Mapping[str, Any], - next_page_parameters: dict[str, Any] | None = None, - _visited_folder_ids: set[str] | None = None, - ): - """ - Get files in a folder. - - Recursively lists all files inside the given folder prefix. - ``_visited_folder_ids`` tracks folders already expanded so that a - self-referencing folder (where the API returns the folder as its own - child) cannot cause infinite recursion. - """ - if _visited_folder_ids is None: - _visited_folder_ids = set() - - # Guard: skip folders we have already expanded to prevent infinite - # recursion from self-referencing folder entries in the API response. - if prefix in _visited_folder_ids: - return - _visited_folder_ids.add(prefix) - - result_generator = datasource_runtime.online_drive_browse_files( - user_id=user_id, - request=OnlineDriveBrowseFilesRequest( - bucket=bucket, - prefix=prefix, - max_keys=20, - next_page_parameters=next_page_parameters, - ), - provider_type=datasource_runtime.datasource_provider_type(), - ) - is_truncated = False - has_files = False - for result in result_generator: - for files in result.result: - for file in files.files: - has_files = True - if file.type == "folder": - if file.id in _visited_folder_ids: - continue - self._get_files_in_folder( - datasource_runtime, - file.id, - bucket, - user_id, - all_files, - datasource_info, - None, - _visited_folder_ids, - ) - else: - all_files.append( - { - "id": file.id, - "name": file.name, - "bucket": bucket, - } - ) - is_truncated = files.is_truncated - next_page_parameters = files.next_page_parameters - - # Guard: only follow pagination when the API actually returned files. - # An empty folder that incorrectly reports ``is_truncated=True`` would - # otherwise recurse forever on the same empty page. - if is_truncated and has_files: - self._get_files_in_folder( - datasource_runtime, - prefix, - bucket, - user_id, - all_files, - datasource_info, - next_page_parameters, - _visited_folder_ids, - ) +__all__ = ["PipelineGenerator"] diff --git a/api/core/plugin/backwards_invocation/app.py b/api/core/plugin/backwards_invocation/app.py index 7a12c5a0be6a7d..f955bc87afcd1b 100644 --- a/api/core/plugin/backwards_invocation/app.py +++ b/api/core/plugin/backwards_invocation/app.py @@ -1,337 +1,10 @@ -import uuid -from collections.abc import Generator, Mapping -from typing import TYPE_CHECKING, Any, Protocol, cast +"""Compatibility exports for the service-owned plugin App invocation boundary. -from sqlalchemy import select -from sqlalchemy.orm import Session +New callers must import from :mod:`services.plugin.app_invocation`. This module +only keeps the historical import path working until the remaining legacy core +plugin callers are retired. +""" -from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict -from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator -from core.app.apps.agent_chat.app_generator import AgentChatAppGenerator -from core.app.apps.chat.app_generator import ChatAppGenerator -from core.app.apps.completion.app_generator import CompletionAppGenerator -from core.app.apps.workflow.app_generator import WorkflowAppGenerator -from core.app.entities.app_invoke_entities import InvokeFrom -from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig -from core.db.session_factory import create_session -from core.plugin.backwards_invocation.base import BaseBackwardsInvocation -from extensions.ext_database import db -from models import Account, TenantAccountJoin -from models.model import ( - App, - AppMode, - AppModelConfig, - AppModelConfigDict, - EndUser, - load_annotation_reply_config, -) -from models.workflow import Workflow +from services.plugin.app_invocation import AppScopedEndUserProvisioner, PluginAppBackwardsInvocation -if TYPE_CHECKING: - from core.app.apps.workflow_app_runner import WorkflowRunDriver - - -class AppScopedEndUserProvisioner(Protocol): - def get_or_create_end_user( - self, - tenant_id: str, - app_id: str, - user_id: str | None = None, - ) -> EndUser: ... - - -class PluginAppBackwardsInvocation(BaseBackwardsInvocation): - @classmethod - def fetch_app_info(cls, app_id: str, tenant_id: str) -> Mapping: - """ - Fetch app info - """ - app = cls._get_app(app_id, tenant_id) - - """Retrieve app parameters.""" - if app.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: - workflow = cls._get_workflow(app) - if workflow is None: - raise ValueError("unexpected app type") - - features_dict: dict[str, Any] = workflow.features_dict - user_input_form = workflow.user_input_form(to_old_structure=True) - else: - app_model_config_dict = cls._get_app_model_config_dict(app) - if app_model_config_dict is None: - raise ValueError("unexpected app type") - - features_dict = cast(dict[str, Any], app_model_config_dict) - - user_input_form = features_dict.get("user_input_form", []) - - return { - "data": get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form), - } - - @classmethod - def invoke_app( - cls, - app_id: str, - user_id: str, - tenant_id: str, - conversation_id: str | None, - query: str | None, - stream: bool, - inputs: Mapping, - files: list[dict], - session: Session, - execution_driver: "WorkflowRunDriver", - end_users: AppScopedEndUserProvisioner, - ) -> Generator[Mapping | str] | Mapping: - """ - invoke app - """ - app = cls._get_app(app_id, tenant_id) - if not user_id: - user = end_users.get_or_create_end_user(app.tenant_id, app.id, None) - else: - try: - user = cls._get_user(user_id, app) - except ValueError: - # Plugins such as WeCom Bot pass external sender IDs rather than EndUser UUIDs. - user = end_users.get_or_create_end_user(app.tenant_id, app.id, user_id) - - conversation_id = conversation_id or "" - - match app.mode: - case AppMode.ADVANCED_CHAT | AppMode.AGENT_CHAT | AppMode.CHAT: - if not query: - raise ValueError("missing query") - - return cls.invoke_chat_app( - app, user, conversation_id, query, stream, inputs, files, session, execution_driver - ) - case AppMode.WORKFLOW: - workflow = cls._get_workflow(app) - if not workflow: - raise ValueError("unexpected app type") - return cls.invoke_workflow_app(app, workflow, user, stream, inputs, files, execution_driver) - case AppMode.COMPLETION: - return cls.invoke_completion_app(app, user, stream, inputs, files, session) - case _: - raise ValueError("unexpected app type") - - @classmethod - def invoke_chat_app( - cls, - app: App, - user: Account | EndUser, - conversation_id: str, - query: str, - stream: bool, - inputs: Mapping, - files: list[dict], - session: Session, - execution_driver: "WorkflowRunDriver", - ) -> Generator[Mapping | str] | Mapping: - """ - invoke chat app - """ - match app.mode: - case AppMode.ADVANCED_CHAT: - workflow = cls._get_workflow(app) - if not workflow: - raise ValueError("unexpected app type") - - pause_config = PauseStateLayerConfig( - session_factory=db.engine, - state_owner_user_id=workflow.created_by, - ) - - return AdvancedChatAppGenerator(execution_driver=execution_driver).generate( - app_model=app, - workflow=workflow, - user=user, - args={ - "inputs": inputs, - "query": query, - "files": files, - "conversation_id": conversation_id, - }, - invoke_from=InvokeFrom.SERVICE_API, - workflow_run_id=str(uuid.uuid4()), - streaming=stream, - pause_state_config=pause_config, - session=session, - ) - case AppMode.AGENT_CHAT: - return AgentChatAppGenerator(execution_driver=execution_driver).generate( - app_model=app, - user=user, - args={ - "inputs": inputs, - "query": query, - "files": files, - "conversation_id": conversation_id, - }, - invoke_from=InvokeFrom.SERVICE_API, - streaming=stream, - session=session, - ) - case AppMode.CHAT: - return ChatAppGenerator().generate( - app_model=app, - user=user, - args={ - "inputs": inputs, - "query": query, - "files": files, - "conversation_id": conversation_id, - }, - invoke_from=InvokeFrom.SERVICE_API, - streaming=stream, - session=session, - ) - case _: - raise ValueError("unexpected app type") - - @classmethod - def invoke_workflow_app( - cls, - app: App, - workflow: Workflow, - user: EndUser | Account, - stream: bool, - inputs: Mapping, - files: list[dict], - execution_driver: "WorkflowRunDriver", - ) -> Generator[Mapping | str] | Mapping: - """ - invoke workflow app - """ - pause_config = PauseStateLayerConfig( - session_factory=db.engine, - state_owner_user_id=workflow.created_by, - ) - - return WorkflowAppGenerator(execution_driver=execution_driver).generate( - app_model=app, - workflow=workflow, - user=user, - args={"inputs": inputs, "files": files}, - invoke_from=InvokeFrom.SERVICE_API, - streaming=stream, - call_depth=1, - pause_state_config=pause_config, - ) - - @classmethod - def invoke_completion_app( - cls, - app: App, - user: EndUser | Account, - stream: bool, - inputs: Mapping, - files: list[dict], - session: Session, - ) -> Generator[Mapping | str] | Mapping: - """ - invoke completion app - """ - return CompletionAppGenerator().generate( - app_model=app, - user=user, - args={"inputs": inputs, "files": files}, - invoke_from=InvokeFrom.SERVICE_API, - streaming=stream, - session=session, - ) - - @classmethod - def _get_user(cls, user_id: str, app: App) -> EndUser | Account: - """ - get the user by user id - """ - with create_session() as session: - stmt = select(EndUser).where( - EndUser.id == user_id, - EndUser.tenant_id == app.tenant_id, - EndUser.app_id == app.id, - ) - user = session.scalar(stmt) - if not user: - stmt = select(EndUser).where( - EndUser.session_id == user_id, - EndUser.tenant_id == app.tenant_id, - EndUser.app_id == app.id, - ) - user = session.scalar(stmt) - if not user: - stmt = select(Account).where( - Account.id == user_id, - Account.id == TenantAccountJoin.account_id, - TenantAccountJoin.tenant_id == app.tenant_id, - ) - user = session.scalar(stmt) - if user: - session.expunge(user) - - if not user: - raise ValueError("user not found") - - return user - - @classmethod - def _get_app(cls, app_id: str, tenant_id: str) -> App: - """ - get app - """ - try: - with create_session() as session: - app = session.scalar(select(App).where(App.id == app_id, App.tenant_id == tenant_id).limit(1)) - if app: - session.expunge(app) - except Exception as e: - raise ValueError("app not found") from e - - if not app: - raise ValueError("app not found") - - return app - - @classmethod - def _get_workflow(cls, app: App) -> Workflow | None: - """ - get workflow with an owned session, additionally scoped by tenant and app - - `App.workflow_with_session` resolves by `workflow_id` alone; a plugin - invocation must also confirm the workflow belongs to this tenant and app. - """ - if not app.workflow_id: - return None - - with create_session() as session: - workflow = session.scalar( - select(Workflow) - .where(Workflow.id == app.workflow_id, Workflow.tenant_id == app.tenant_id, Workflow.app_id == app.id) - .limit(1) - ) - if workflow: - session.expunge(workflow) - return workflow - - @classmethod - def _get_app_model_config_dict(cls, app: App) -> AppModelConfigDict | None: - """ - get app model config features without relying on request-scoped session-backed model properties - """ - if not app.app_model_config_id: - return None - - with create_session() as session: - app_model_config = session.scalar( - select(AppModelConfig) - .where(AppModelConfig.id == app.app_model_config_id, AppModelConfig.app_id == app.id) - .limit(1) - ) - if app_model_config is None: - return None - - annotation_reply = load_annotation_reply_config(session, app_model_config.app_id) - return app_model_config.to_dict(annotation_reply=annotation_reply) +__all__ = ["AppScopedEndUserProvisioner", "PluginAppBackwardsInvocation"] diff --git a/api/core/tools/workflow_as_tool/tool.py b/api/core/tools/workflow_as_tool/tool.py index dc46263692af91..bf4c990d06d9da 100644 --- a/api/core/tools/workflow_as_tool/tool.py +++ b/api/core/tools/workflow_as_tool/tool.py @@ -1,54 +1,39 @@ -from __future__ import annotations +"""Compatibility wrapper for the service-owned nested workflow tool. + +New callers must import :class:`services.tools.workflow.tool.WorkflowTool` and +provide its execution dependencies directly. The legacy core tool stack still +constructs workflow tools while materializing provider metadata, so this +wrapper temporarily preserves that constructor and its ``execution_driver`` +attribute until the core stack is retired. +""" -import contextlib -import json -import logging -from collections.abc import Generator, Mapping, Sequence -from typing import TYPE_CHECKING, Any, cast, override +from __future__ import annotations -from sqlalchemy import select -from sqlalchemy.orm import Session +from collections.abc import Callable +from typing import TYPE_CHECKING, override -from core.app.file_access import DatabaseFileAccessController -from core.db.session_factory import session_factory -from core.helper.trace_id_helper import ( - ParentTraceContext, - extract_parent_trace_context_from_args, - extract_trace_session_id_from_args, -) +from core.app.apps.draft_variable_saver import DraftVariableSaverFactory +from core.helper.trace_id_helper import ParentTraceContext from core.tools.__base.tool import Tool from core.tools.__base.tool_runtime import ToolRuntime -from core.tools.entities.tool_entities import ( - ToolEntity, - ToolInvokeMessage, - ToolParameter, - ToolProviderType, -) -from core.tools.errors import ToolInvokeError -from core.workflow.file_reference import resolve_file_record_id -from factories.file_factory import build_from_mapping -from graphon.enums import WorkflowExecutionStatus -from graphon.file import FILE_MODEL_IDENTITY, File, FileTransferMethod -from graphon.model_runtime.entities.llm_entities import LLMUsage, LLMUsageMetadata -from models import Account, Tenant -from models.model import App, EndUser -from models.utils.file_input_compat import build_file_from_stored_mapping -from models.workflow import Workflow +from core.tools.entities.tool_entities import ToolEntity +from graphon.model_runtime.entities.llm_entities import LLMUsage +from models import Account +from models.tool_runtime_contracts import WorkflowToolQueries +from services.tools.workflow.tool import WorkflowTool as ServiceWorkflowTool +from services.workflow.execution.ports import WorkflowRuntime if TYPE_CHECKING: - from core.app.apps.workflow_app_runner import WorkflowRunDriver + from services.workflow.execution.contracts import WorkflowRunDriver -logger = logging.getLogger(__name__) -_file_access_controller = DatabaseFileAccessController() +class WorkflowTool(ServiceWorkflowTool): + """Keep the legacy construction surface without restoring hidden dependencies. -class WorkflowTool(Tool): + Metadata-only legacy callers may omit the new execution ports. Invocation + remains guarded by the canonical implementation, which raises when no + ``WorkflowRuntime`` was injected. """ - Workflow tool. - """ - - _parent_trace_context: ParentTraceContext | None - _trace_session_id: str | None def __init__( self, @@ -61,184 +46,63 @@ def __init__( runtime: ToolRuntime, label: str = "Workflow", execution_driver: WorkflowRunDriver | None = None, - ): - self.workflow_app_id = workflow_app_id - self.workflow_id = workflow_id - self.workflow_as_tool_id = workflow_as_tool_id - self.version = version - self.workflow_call_depth = workflow_call_depth - self.label = label - self.execution_driver = execution_driver - self._latest_usage = LLMUsage.empty_usage() - self._parent_trace_context = None - self._trace_session_id = None - - super().__init__(entity=entity, runtime=runtime) - - @override - def tool_provider_type(self) -> ToolProviderType: - """ - get the tool provider type - - :return: the tool provider type - """ - return ToolProviderType.WORKFLOW - - @override - def _invoke( - self, - session: Session, - user_id: str, - tool_parameters: dict[str, Any], - conversation_id: str | None = None, - app_id: str | None = None, - message_id: str | None = None, - ) -> Generator[ToolInvokeMessage]: - """ - invoke the tool - """ - app = self._get_app(app_id=self.workflow_app_id) - workflow = self._get_workflow(app_id=self.workflow_app_id, version=self.version) - - # transform the tool parameters - tool_parameters, files = self._transform_args(tool_parameters=tool_parameters) - - from core.app.apps.workflow.app_generator import WorkflowAppGenerator - - if self.execution_driver is None: - raise ToolInvokeError("Workflow execution driver is required") - generator = WorkflowAppGenerator(execution_driver=self.execution_driver) - assert self.runtime is not None - assert self.runtime.invoke_from is not None - - user = self._resolve_user(user_id=user_id) - if user is None: - raise ToolInvokeError("User not found") - - self._latest_usage = LLMUsage.empty_usage() - - generator_args: dict[str, Any] = {"inputs": tool_parameters, "files": files} - if self._parent_trace_context: - generator_args.update( - extract_parent_trace_context_from_args({"parent_trace_context": self._parent_trace_context}) + *, + queries: WorkflowToolQueries | None = None, + draft_variable_saver: Callable[[str, Account], DraftVariableSaverFactory] | None = None, + workflow_runtime: WorkflowRuntime | None = None, + ) -> None: + if queries is None and workflow_runtime is not None: + queries = workflow_runtime.tools + + if queries is not None: + super().__init__( + workflow_app_id=workflow_app_id, + workflow_id=workflow_id, + workflow_as_tool_id=workflow_as_tool_id, + version=version, + workflow_call_depth=workflow_call_depth, + entity=entity, + runtime=runtime, + label=label, + queries=queries, + draft_variable_saver=draft_variable_saver, + workflow_runtime=workflow_runtime, ) - if self._trace_session_id: - generator_args.update(extract_trace_session_id_from_args({"trace_session_id": self._trace_session_id})) - - result = generator.generate( - app_model=app, - workflow=workflow, - user=user, - args=generator_args, - invoke_from=self.runtime.invoke_from, - streaming=False, - call_depth=self.workflow_call_depth + 1, - # NOTE(QuantumGhost): We explicitly set `pause_state_config` to `None` - # because workflow pausing mechanisms (such as HumanInput) are not - # supported within WorkflowTool execution context. - pause_state_config=None, - allow_human_input=False, - ) - assert isinstance(result, dict) - data = result.get("data", {}) - - if data.get("status") == WorkflowExecutionStatus.PAUSED: - raise ToolInvokeError("Paused Workflow Tool execution requires Engine-managed container execution.") - - if err := data.get("error"): - raise ToolInvokeError(err) - - outputs = data.get("outputs") - if outputs is None: - outputs = {} else: - outputs, files = self._extract_files(outputs) # type: ignore - for file in files: - yield self.create_file_message(file) # type: ignore - - # traverse `outputs` field and create variable messages - for key, value in outputs.items(): - if key not in {"text", "json", "files"}: - yield self.create_variable_message(variable_name=key, variable_value=value) - - self._latest_usage = self._derive_usage_from_result(data) - - yield self.create_text_message(json.dumps(outputs, ensure_ascii=False)) - yield self.create_json_message(outputs, suppress_output=True) + # The legacy provider also materializes tool metadata for list and + # configuration APIs. It has no execution composition root, so do + # not invent one; initialize only the state those APIs require. + self.workflow_app_id = workflow_app_id + self.workflow_id = workflow_id + self.workflow_as_tool_id = workflow_as_tool_id + self.version = version + self.workflow_call_depth = workflow_call_depth + self.label = label + self._draft_variable_saver = draft_variable_saver + self._workflow_runtime = None + self._latest_usage = LLMUsage.empty_usage() + self._parent_trace_context: ParentTraceContext | None = None + self._trace_session_id: str | None = None + Tool.__init__(self, entity=entity, runtime=runtime) + + self._legacy_queries = queries + self._legacy_execution_driver = execution_driver @property - def latest_usage(self) -> LLMUsage: - return self._latest_usage - - def prepare_container_inputs( - self, - tool_parameters: Mapping[str, Any], - ) -> tuple[dict[str, Any], list[dict[str, str | None]]]: - """Merge configured parameters and convert them into workflow inputs.""" - merged_parameters = {**tool_parameters, **self.runtime.runtime_parameters} - typed_parameters = self._transform_tool_parameters_type(merged_parameters) - return self._transform_args(tool_parameters=typed_parameters) - - @classmethod - def _derive_usage_from_result(cls, data: Mapping[str, Any]) -> LLMUsage: - usage_dict = cls._extract_usage_dict(data) - if usage_dict is not None: - return LLMUsage.from_metadata(cast(LLMUsageMetadata, dict(usage_dict))) - - total_tokens = data.get("total_tokens") - total_price = data.get("total_price") - if total_tokens is None and total_price is None: - return LLMUsage.empty_usage() - - usage_metadata: dict[str, Any] = {} - if total_tokens is not None: - with contextlib.suppress(TypeError, ValueError): - usage_metadata["total_tokens"] = int(str(total_tokens)) - if total_price is not None: - usage_metadata["total_price"] = str(total_price) - currency = data.get("currency") - if currency is not None: - usage_metadata["currency"] = currency - - if not usage_metadata: - return LLMUsage.empty_usage() + def execution_driver(self) -> WorkflowRunDriver | None: + """Preserve the mutable attribute used by the retiring core manager.""" + if self._workflow_runtime is not None: + return self._workflow_runtime.execution_driver + return self._legacy_execution_driver - return LLMUsage.from_metadata(cast(LLMUsageMetadata, usage_metadata)) - - @classmethod - def _extract_usage_dict(cls, payload: Mapping[str, Any]) -> Mapping[str, Any] | None: - usage_candidate = payload.get("usage") - if isinstance(usage_candidate, Mapping): - return usage_candidate - - metadata_candidate = payload.get("metadata") - if isinstance(metadata_candidate, Mapping): - usage_candidate = metadata_candidate.get("usage") - if isinstance(usage_candidate, Mapping): - return usage_candidate - - for value in payload.values(): - match value: - case _ if isinstance(value, Mapping): - found = cls._extract_usage_dict(value) - if found is not None: - return found - case _ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): - for item in value: - if isinstance(item, Mapping): - found = cls._extract_usage_dict(item) - if found is not None: - return found - return None + @execution_driver.setter + def execution_driver(self, execution_driver: WorkflowRunDriver | None) -> None: + self._legacy_execution_driver = execution_driver @override def fork_tool_runtime(self, runtime: ToolRuntime) -> WorkflowTool: - """ - fork a new tool with metadata - - :return: the new tool - """ forked = self.__class__( + queries=self._legacy_queries, entity=self.entity.model_copy(), runtime=runtime, workflow_app_id=self.workflow_app_id, @@ -247,211 +111,13 @@ def fork_tool_runtime(self, runtime: ToolRuntime) -> WorkflowTool: workflow_call_depth=self.workflow_call_depth, version=self.version, label=self.label, - execution_driver=self.execution_driver, + execution_driver=self._legacy_execution_driver, + draft_variable_saver=self._draft_variable_saver, + workflow_runtime=self._workflow_runtime, ) forked._parent_trace_context = self._parent_trace_context.model_copy() if self._parent_trace_context else None forked._trace_session_id = self._trace_session_id return forked - def set_parent_trace_context( - self, - *, - parent_workflow_run_id: str, - parent_node_execution_id: str, - ) -> None: - """Attach outer workflow trace context without exposing it as tool input.""" - self._parent_trace_context = ParentTraceContext( - parent_workflow_run_id=parent_workflow_run_id, - parent_node_execution_id=parent_node_execution_id, - ) - - def clear_parent_trace_context(self) -> None: - """Remove parent trace context before invoking this tool outside a nested workflow.""" - self._parent_trace_context = None - - def set_trace_session_id(self, trace_session_id: str) -> None: - """Attach parent trace session ID without exposing it as tool input.""" - self._trace_session_id = trace_session_id - - def clear_trace_session_id(self) -> None: - """Remove trace session ID before invoking this tool outside a traced session.""" - self._trace_session_id = None - - def _resolve_user(self, user_id: str) -> Account | EndUser | None: - """ - Resolve user object in both HTTP and worker contexts. - - In HTTP context: dereference the current_user LocalProxy (can return Account or EndUser). - In worker context: load Account(knowledge pipeline) or EndUser(trigger) from database by user_id. - - Returns: - Account | EndUser | None: The resolved user object, or None if resolution fails. - """ - return self._resolve_user_from_database(user_id=user_id) - - def _resolve_user_from_database(self, user_id: str) -> Account | EndUser | None: - """ - Resolve user from database (worker/Celery context). - """ - with session_factory.create_session() as session: - tenant_stmt = select(Tenant).where(Tenant.id == self.runtime.tenant_id) - tenant = session.scalar(tenant_stmt) - if not tenant: - return None - - user_stmt = select(Account).where(Account.id == user_id) - user = session.scalar(user_stmt) - if user: - user.set_current_tenant_with_session(tenant, session=session) - session.expunge(user) - return user - - end_user_stmt = select(EndUser).where(EndUser.id == user_id, EndUser.tenant_id == tenant.id) - end_user = session.scalar(end_user_stmt) - if end_user: - session.expunge(end_user) - return end_user - - return None - - def _get_workflow(self, app_id: str, version: str) -> Workflow: - """ - get the workflow by app id and version - """ - with session_factory.create_session() as session, session.begin(): - if not version: - stmt = ( - select(Workflow) - .where(Workflow.app_id == app_id, Workflow.version != Workflow.VERSION_DRAFT) - .order_by(Workflow.created_at.desc()) - ) - workflow = session.scalars(stmt).first() - else: - stmt = select(Workflow).where(Workflow.app_id == app_id, Workflow.version == version) - workflow = session.scalar(stmt) - - if not workflow: - raise ValueError("workflow not found or not published") - - session.expunge(workflow) - return workflow - - def _get_app(self, app_id: str) -> App: - """ - get the app by app id - """ - stmt = select(App).where(App.id == app_id) - with session_factory.create_session() as session, session.begin(): - app = session.scalar(stmt) - if not app: - raise ValueError("app not found") - - session.expunge(app) - return app - - def _transform_args(self, tool_parameters: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, str | None]]]: - """ - transform the tool parameters - - :param tool_parameters: the tool parameters - :return: tool_parameters, files - """ - parameter_rules = self.get_merged_runtime_parameters() - parameters_result = {} - files = [] - for parameter in parameter_rules: - if parameter.type == ToolParameter.ToolParameterType.SYSTEM_FILES: - file = tool_parameters.get(parameter.name) - if file: - try: - file_var_list = [ - build_file_from_stored_mapping( - file_mapping=cast(Mapping[str, Any], f), - tenant_id=str(self.runtime.tenant_id), - ) - for f in file - if isinstance(f, Mapping) - ] - for file in file_var_list: - file_dict: dict[str, str | None] = { - "transfer_method": file.transfer_method.value, - "type": file.type.value, - } - match file.transfer_method: - case FileTransferMethod.TOOL_FILE: - file_dict["tool_file_id"] = resolve_file_record_id(file.reference) - case FileTransferMethod.LOCAL_FILE: - file_dict["upload_file_id"] = resolve_file_record_id(file.reference) - case FileTransferMethod.DATASOURCE_FILE: - file_dict["datasource_file_id"] = resolve_file_record_id(file.reference) - case FileTransferMethod.REMOTE_URL: - file_dict["url"] = file.generate_url() - - files.append(file_dict) - except Exception: - logger.exception("Failed to transform file %s", file) - elif parameter.type == ToolParameter.ToolParameterType.FILES: - value = tool_parameters.get(parameter.name) - if not parameter.required and self._is_empty_files_parameter_value(value): - value = [] - parameters_result[parameter.name] = value - else: - parameters_result[parameter.name] = tool_parameters.get(parameter.name) - - return parameters_result, files - - @staticmethod - def _is_empty_files_parameter_value(value: Any) -> bool: - """Identify empty optional file-list placeholders before workflow input validation.""" - - if value is None or value == "": - return True - return isinstance(value, list) and all(item is None or item == "" for item in value) - - def _extract_files(self, outputs: dict[str, Any]) -> tuple[dict[str, Any], list[File]]: - """ - extract files from the result - - :return: the result, files - """ - files: list[File] = [] - result = {} - for key, value in outputs.items(): - match value: - case list(): - for item in value: - if isinstance(item, dict) and item.get("dify_model_identity") == FILE_MODEL_IDENTITY: - item = self._update_file_mapping(item) - file = build_from_mapping( - mapping=item, - tenant_id=str(self.runtime.tenant_id), - access_controller=_file_access_controller, - ) - files.append(file) - case dict() if value.get("dify_model_identity") == FILE_MODEL_IDENTITY: - value = self._update_file_mapping(value) - file = build_from_mapping( - mapping=value, - tenant_id=str(self.runtime.tenant_id), - access_controller=_file_access_controller, - ) - files.append(file) - - result[key] = value - - return result, files - def _update_file_mapping(self, file_dict: dict[str, Any]) -> dict[str, Any]: - file_id = resolve_file_record_id(file_dict.get("reference") or file_dict.get("related_id")) - transfer_method_value = file_dict.get("transfer_method") - if not isinstance(transfer_method_value, str): - raise ValueError("Workflow file mapping is missing a valid transfer_method") - transfer_method = FileTransferMethod.value_of(transfer_method_value) - match transfer_method: - case FileTransferMethod.TOOL_FILE: - file_dict["tool_file_id"] = file_id - case FileTransferMethod.LOCAL_FILE: - file_dict["upload_file_id"] = file_id - case FileTransferMethod.REMOTE_URL | FileTransferMethod.DATASOURCE_FILE: - pass - return file_dict +__all__ = ["WorkflowTool"] From b0e450577453dbbfbb776df4f622c876bae2c3ea Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Sat, 31 Oct 2026 22:50:14 +0800 Subject: [PATCH 7/7] Update human input tests to canonical adapters --- .../app/test_human_input_form_compatibility.py | 12 +++++++----- .../controllers/web/test_human_input_form.py | 2 +- .../services/human_input_v2/test_composition.py | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_human_input_form_compatibility.py b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form_compatibility.py index 82a1a892e29626..9dda47e1e405c9 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_human_input_form_compatibility.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form_compatibility.py @@ -26,7 +26,6 @@ from controllers.service_api import wraps from controllers.service_api.app import human_input_form as controller -from enums.human_input import ButtonStyle from core.human_input_v2.resolved_form import ( FileInput, FileListInput, @@ -37,7 +36,7 @@ ) from core.human_input_v2.shared.values import TenantId from core.workflow.nodes.human_input_v2.entities import Initiator -from enums.human_input import HumanInputFormStatus, RecipientType +from enums.human_input import ButtonStyle, HumanInputFormStatus, RecipientType from extensions.ext_application_services import ApplicationServices from extensions.storage.storage_type import StorageType from graphon.enums import WorkflowExecutionStatus @@ -396,8 +395,6 @@ def test_preserves_paragraph_json_values_and_ignores_extra_input_fields(approval ], ) def test_v1_json_paragraph_submission_resumes_the_node(approval: Approval, note: JsonValue, rendered_note: str) -> None: - from core.repositories.human_input_repository import HumanInputFormRepositoryImpl - from core.workflow.nodes.human_input.callback import DifyHITLCallback from core.workflow.nodes.human_input.constants import OUTPUT_FIELD_RENDERED_CONTENT from graphon.engine_events import NodeRunSucceededEvent from graphon.enums import WorkflowNodeExecutionStatus @@ -406,6 +403,8 @@ def test_v1_json_paragraph_submission_resumes_the_node(approval: Approval, note: from graphon.runtime import RuntimeState, VariablePool from graphon.runtime.init_params import InitParams from models.human_input_entities import HumanInputNodeData + from repositories.human_input.form_repository import HumanInputFormRepositoryImpl + from services.workflow.execution.adapters.human_input import DifyHITLCallback response = approval.submit(inputs={"note": note, "choice": "Yes"}) assert response.status_code == 200, response.json @@ -420,7 +419,10 @@ def test_v1_json_paragraph_submission_resumes_the_node(approval: Approval, note: user_actions=definition.user_actions, ) repository = HumanInputFormRepositoryImpl( - tenant_id=approval.tenant_id, app_id=approval.app_id, workflow_execution_id=approval.run_id + tenant_id=approval.tenant_id, + app_id=approval.app_id, + workflow_execution_id=approval.run_id, + sessions=approval.sessions, ) node = HumanInputNode( node_id="approval", diff --git a/api/tests/unit_tests/controllers/web/test_human_input_form.py b/api/tests/unit_tests/controllers/web/test_human_input_form.py index 40ab5805935be5..eb8b111e92dd88 100644 --- a/api/tests/unit_tests/controllers/web/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/web/test_human_input_form.py @@ -15,11 +15,11 @@ import controllers.web.human_input_form as human_input_module from controllers.common.errors import AccessDeniedError, NotFoundError from controllers.web.error import WebFormRateLimitExceededError -from core.repositories.human_input_repository import HumanInputFormRecord from enums.human_input import HumanInputFormKind, HumanInputFormStatus, RecipientType, ValueSourceType from models import Tenant from models.account import TenantStatus from models.enums import CustomizeTokenStrategy +from models.human_input_contracts import HumanInputFormRecord from models.human_input_entities import FormDefinition, ParagraphInputConfig, SelectInputConfig, StringListSource from models.model import App, AppMode, IconType, Site from services.entities.feature_entities import FeatureModel diff --git a/api/tests/unit_tests/services/human_input_v2/test_composition.py b/api/tests/unit_tests/services/human_input_v2/test_composition.py index 46ea8c9ce7fa64..176639587595bb 100644 --- a/api/tests/unit_tests/services/human_input_v2/test_composition.py +++ b/api/tests/unit_tests/services/human_input_v2/test_composition.py @@ -12,7 +12,6 @@ from core.db.session_factory import session_factory from core.helper import encrypter from core.human_input_v2.shared.values import AccountId, TenantId -from core.workflow.node_factory import DifyNodeFactory from core.workflow.nodes.human_input_v2.events import NodeRunHumanInputV2FormRequiredEvent from core.workflow.system_variables import SystemVariableKey, system_variable_selector from enums import DeploymentEdition @@ -29,6 +28,7 @@ from models.human_input_v2 import ContactSubjectType, HumanInputContactIdentity, HumanInputForm from models.workflow import WorkflowPause, WorkflowRun, WorkflowType from services.human_input_v2.submission_service import HumanInputSubmissionService +from services.workflow.execution.adapters.node_factory import DifyNodeFactory from tasks.app_generate.workflow_execute_task import resume_app_execution from tasks.human_input_im_card_tasks import update_human_input_im_card_task