diff --git a/api/.importlinter b/api/.importlinter index 8b933a6c06f5ab..c158d3f3d49180 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -32,57 +32,45 @@ layers = # error makes this list shrink whenever a legacy dependency is removed. unmatched_ignore_imports_alerting = error ignore_imports = - core.app.apps.advanced_chat.app_runner -> extensions.workflow_warm_shutdown - core.app.apps.workflow.app_runner -> extensions.workflow_warm_shutdown - # The Dify node factory composes the Human Input runtime for workflow execution. - core.workflow.node_factory -> services.human_input_v2.composition - # Legacy generators delegate configured file defaults to the application owner during core migration. + # The legacy base generator delegates file defaults to the application owner until cleanup. core.app.apps.base_app_generator -> services.app.file_input_defaults - core.app.apps.advanced_chat.app_generator -> services.app.file_input_defaults - core.app.apps.workflow.app_generator -> services.app.file_input_defaults 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 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 # KnowledgeFS agent glue lives in services; agent runners only project its config and citations. core.app.apps.agent_app.app_runner -> services.agent.knowledge_citations - core.app.apps.agent_app.runtime_request_builder -> services.agent.prompt_mentions core.app.apps.agent_app.runtime_request_builder -> services.agent.knowledge_runtime_config 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.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.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_draft_variable_service - # Legacy debug callers delegate input hydration to the application owner during core migration. - core.app.apps.workflow_app_runner -> services.workflow_node_variables + 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 # The legacy runners share contracts until their service replacements are activated. core.app.apps.workflow_app_runner -> services.workflow.execution.contracts - core.app.apps.workflow_app_runner -> services.human_input.ports - core.app.apps.pipeline.pipeline_runner -> services.workflow.execution.contracts - core.app.apps.advanced_chat.app_runner -> services.conversation_variable_updater - core.workflow.workflow_entry -> services.workflow_node_variables - core.app.apps.workflow_app_runner -> tasks.mail_human_input_delivery_task 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.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 @@ -100,22 +88,10 @@ ignore_imports = core.repositories.sqlalchemy_workflow_node_execution_repository -> services.variable_truncator 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 + core.workflow.nodes.agent_v2.runtime_feature_manifest -> services.agent.knowledge_datasets # Node construction delegates datasource dependency assembly to the composition root. - core.workflow.node_factory -> extensions.application_services.data_sources # The KnowledgeFS retrieval node is owned by services.knowledge_fs and only wired in here. - core.workflow.node_factory -> services.knowledge_fs.knowledge_retrieval_v2.automatic_metadata_filter - core.workflow.node_factory -> services.knowledge_fs.knowledge_retrieval_v2.entities - core.workflow.nodes.agent_v2.agent_node -> services.agent.knowledge_citations - 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.agent.knowledge_runtime_config - 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/controllers/openapi/node_types.py b/api/controllers/openapi/node_types.py index 0e912c77dddb25..4107474ea462f8 100644 --- a/api/controllers/openapi/node_types.py +++ b/api/controllers/openapi/node_types.py @@ -14,7 +14,7 @@ from controllers.openapi._errors import NodeTypeNotFound from controllers.openapi._models import NodeTypeDetailResponse, NodeTypeListResponse, NodeTypeRow from controllers.openapi.auth.context import Context -from core.workflow.node_factory import LATEST_VERSION, get_node_type_classes_mapping +from services.workflow.execution.adapters.node_factory import LATEST_VERSION, get_node_type_classes_mapping from services.workflow.node_defaults import complete_sub_models, node_data_type from services.workflow_service import WorkflowService diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py index 1b7f217058525c..65e1b4f6d77016 100644 --- a/api/core/app/apps/advanced_chat/app_generator.py +++ b/api/core/app/apps/advanced_chat/app_generator.py @@ -1,787 +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.app.file_input_defaults import grant_file_default_access -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, - ): - grant_file_default_access(application_generate_entity.app_config.variables) - 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/advanced_chat/app_runner.py b/api/core/app/apps/advanced_chat/app_runner.py index 3d2e29e1993afa..617762dab5e11a 100644 --- a/api/core/app/apps/advanced_chat/app_runner.py +++ b/api/core/app/apps/advanced_chat/app_runner.py @@ -1,468 +1,5 @@ -import logging -import time -from collections.abc import Mapping -from typing import Any, cast, override +"""Compatibility import for the workflow execution service runner.""" -from sqlalchemy import select -from sqlalchemy.orm import Session +from services.workflow.execution.adapters.chatflow.app_runner import AdvancedChatAppRunner -from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfig -from core.app.apps.base_app_queue_manager import AppQueueManager -from core.app.apps.workflow.command_channels import ( - CelerySignalCommandChannel, - CombinedCommandChannel, - StopFlagCommandChannel, -) -from core.app.apps.workflow.stop_aware_ready_queue import attach_stop_aware_ready_queue -from core.app.apps.workflow_app_runner import PreparedWorkflowRun, WorkflowBasedAppRunner, WorkflowRunDriver -from core.app.entities.app_invoke_entities import ( - AdvancedChatAppGenerateEntity, - AppGenerateEntity, - InvokeFrom, - get_credit_usage_app_type, -) -from core.app.entities.queue_entities import ( - QueueAnnotationReplyEvent, - QueueStopEvent, - QueueTextChunkEvent, -) -from core.app.features.annotation_reply.annotation_reply import AnnotationReplyFeature -from core.app.layers.conversation_variable_persist_layer import ConversationVariablePersistenceLayer -from core.db.session_factory import create_session, session_factory -from core.moderation.base import ModerationError -from core.moderation.input_moderation import InputModeration -from core.workflow.node_factory import get_default_root_node_id -from core.workflow.system_variables import ( - build_bootstrap_variables, - build_system_variables, - 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.workflow_warm_shutdown import WORKFLOW_WARM_SHUTDOWN_ABORT_REASON, celery_warm_shutdown_started -from graphon.engine.command import RedisChannel -from graphon.engine.filter import ResponseStreamFilter -from graphon.enums import WorkflowType -from graphon.runtime import RuntimeState, VariablePool -from graphon.variable_loader import VariableLoader -from graphon.variables.variables import Variable -from models import Workflow -from models.model import App, Conversation, Message, MessageAnnotation -from models.workflow import ConversationVariable -from services.conversation_variable_updater import ConversationVariableUpdater - -logger = logging.getLogger(__name__) - - -class AdvancedChatAppRunner(WorkflowBasedAppRunner): - """ - AdvancedChat Application Runner - """ - - def __init__( - self, - *, - application_generate_entity: AdvancedChatAppGenerateEntity, - queue_manager: AppQueueManager, - conversation: Conversation, - message: Message, - dialogue_count: int, - variable_loader: VariableLoader, - workflow: Workflow, - system_user_id: str, - app: App, - execution_driver: WorkflowRunDriver | None = None, - graph_runtime_state: RuntimeState | None = None, - response_stream_filter: ResponseStreamFilter | None = None, - ): - super().__init__( - queue_manager=queue_manager, - execution_driver=execution_driver, - variable_loader=variable_loader, - app_id=application_generate_entity.app_config.app_id, - ) - self.application_generate_entity = application_generate_entity - self.conversation = conversation - self.message = message - self._dialogue_count = dialogue_count - self._workflow = workflow - self.system_user_id = system_user_id - self._app = app - self._resume_graph_runtime_state = graph_runtime_state - self._response_stream_filter = response_stream_filter - - @override - def prepare(self) -> PreparedWorkflowRun | None: - app_config = self.application_generate_entity.app_config - app_config = cast(AdvancedChatAppConfig, app_config) - - system_inputs = build_system_variables( - query=self.application_generate_entity.query, - files=self.application_generate_entity.files, - conversation_id=self.conversation.id, - user_id=self.system_user_id, - dialogue_count=self._dialogue_count, - app_id=app_config.app_id, - workflow_id=app_config.workflow_id, - workflow_execution_id=self.application_generate_entity.workflow_run_id, - ) - - with create_session() as session: - app_record = session.scalar(select(App).where(App.id == app_config.app_id)) - - if not app_record: - raise ValueError("App not found") - - invoke_from = self.application_generate_entity.invoke_from - if self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run: - invoke_from = InvokeFrom.DEBUGGER - user_from = self._resolve_user_from(invoke_from) - - resume_state = self._resume_graph_runtime_state - - if resume_state is not None: - graph_runtime_state = resume_state - variable_pool = graph_runtime_state.variable_pool - graph = self._init_graph( - graph_config=self._workflow.graph_dict, - graph_runtime_state=graph_runtime_state, - workflow_id=self._workflow.id, - tenant_id=self._workflow.tenant_id, - user_id=self.application_generate_entity.user_id, - invoke_from=invoke_from, - user_from=user_from, - app_type=get_credit_usage_app_type(app_config.app_mode), - trace_session_id=self.application_generate_entity.extras.get("trace_session_id"), - call_depth=self.application_generate_entity.call_depth, - ) - elif self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run: - # Handle single iteration or single loop run - graph, variable_pool, graph_runtime_state = self._prepare_single_node_execution( - workflow=self._workflow, - single_iteration_run=self.application_generate_entity.single_iteration_run, - single_loop_run=self.application_generate_entity.single_loop_run, - user_id=self.application_generate_entity.user_id, - app_type=get_credit_usage_app_type(app_config.app_mode), - trace_session_id=self.application_generate_entity.extras.get("trace_session_id"), - ) - else: - inputs = self.application_generate_entity.inputs - query = self.application_generate_entity.query - - # moderation - stop, new_inputs, new_query = self.handle_input_moderation( - app_record=self._app, - app_generate_entity=self.application_generate_entity, - inputs=inputs, - query=query, - message_id=self.message.id, - ) - if stop: - return None - - self.application_generate_entity.inputs = new_inputs - self.application_generate_entity.query = new_query - system_inputs = build_system_variables( - system_variables_to_mapping(system_inputs), - query=new_query, - ) - - # annotation reply - with create_session() as session: - annotation_reply = self.handle_annotation_reply( - app_record=self._app, - message=self.message, - query=new_query, - app_generate_entity=self.application_generate_entity, - session=session, - ) - session.commit() - if annotation_reply: - self._publish_event(QueueAnnotationReplyEvent(message_annotation_id=annotation_reply.id)) - self._complete_with_stream_output( - text=annotation_reply.content, - stopped_by=QueueStopEvent.StopBy.ANNOTATION_REPLY, - ) - return None - - # Initialize conversation variables - conversation_variables = self._initialize_conversation_variables() - - # Create a variable pool. - # init variable pool - variable_pool = VariablePool() - add_variables_to_pool( - variable_pool, - build_bootstrap_variables( - system_variables=system_inputs, - environment_variables=self._workflow.environment_variables, - conversation_variables=conversation_variables, - ), - ) - root_node_id = get_default_root_node_id(self._workflow.graph_dict) - add_node_inputs_to_pool(variable_pool, node_id=root_node_id, inputs=new_inputs) - - # init graph - graph_runtime_state = RuntimeState( - workflow_id=self._workflow.id, variable_pool=variable_pool, start_at=time.time() - ) - graph = self._init_graph( - graph_config=self._workflow.graph_dict, - graph_runtime_state=graph_runtime_state, - workflow_id=self._workflow.id, - tenant_id=self._workflow.tenant_id, - user_id=self.application_generate_entity.user_id, - user_from=user_from, - invoke_from=invoke_from, - root_node_id=root_node_id, - app_type=get_credit_usage_app_type(app_config.app_mode), - trace_session_id=self.application_generate_entity.extras.get("trace_session_id"), - call_depth=self.application_generate_entity.call_depth, - ) - - # RUN WORKFLOW - # Create Redis command channel for this workflow execution - task_id = self.application_generate_entity.task_id - channel_key = f"workflow:{task_id}:commands" - celery_signal_channel = CelerySignalCommandChannel( - shutdown_state_getter=celery_warm_shutdown_started, - abort_reason=WORKFLOW_WARM_SHUTDOWN_ABORT_REASON, - ) - attach_stop_aware_ready_queue(graph_runtime_state, task_id=task_id) - command_channel = CombinedCommandChannel( - ( - RedisChannel(redis_client, channel_key), - StopFlagCommandChannel(task_id=task_id), - celery_signal_channel, - ) - ) - - workflow_entry = WorkflowEntry( - graph=graph, - graph_config=self._workflow.graph_dict, - call_depth=self.application_generate_entity.call_depth, - graph_runtime_state=graph_runtime_state, - command_channel=command_channel, - response_stream_filter=self._response_stream_filter, - ) - - self._queue_manager.graph_runtime_state = graph_runtime_state - - return PreparedWorkflowRun( - entry=workflow_entry, - application_layers=( - ConversationVariablePersistenceLayer(ConversationVariableUpdater(session_factory.get_session_maker())), - *self._graph_engine_layers, - ), - generate_entity=self.application_generate_entity, - workflow_type=WorkflowType(self._workflow.type), - workflow_version=self._workflow.version, - ) - - def handle_input_moderation( - self, - app_record: App, - app_generate_entity: AdvancedChatAppGenerateEntity, - inputs: Mapping[str, Any], - query: str, - message_id: str, - ) -> tuple[bool, Mapping[str, Any], str]: - try: - # process sensitive_word_avoidance - _, new_inputs, new_query = self.moderation_for_inputs( - app_id=app_record.id, - tenant_id=app_generate_entity.app_config.tenant_id, - app_generate_entity=app_generate_entity, - inputs=inputs, - query=query, - message_id=message_id, - ) - except ModerationError as e: - self._complete_with_stream_output(text=str(e), stopped_by=QueueStopEvent.StopBy.INPUT_MODERATION) - return True, inputs, query - - return False, new_inputs, new_query - - def handle_annotation_reply( - self, - app_record: App, - message: Message, - query: str, - app_generate_entity: AdvancedChatAppGenerateEntity, - session: Session, - ) -> MessageAnnotation | None: - return self.query_app_annotations_to_reply( - app_record=app_record, - message=message, - query=query, - user_id=app_generate_entity.user_id, - invoke_from=app_generate_entity.invoke_from, - session=session, - ) - - def _complete_with_stream_output(self, text: str, stopped_by: QueueStopEvent.StopBy): - """ - Direct output - """ - self._publish_event(QueueTextChunkEvent(text=text)) - - self._publish_event(QueueStopEvent(stopped_by=stopped_by)) - - def query_app_annotations_to_reply( - self, - app_record: App, - message: Message, - query: str, - user_id: str, - invoke_from: InvokeFrom, - session: Session, - ) -> MessageAnnotation | None: - """ - Query app annotations to reply - :param app_record: app record - :param message: message - :param query: query - :param user_id: user id - :param invoke_from: invoke from - :return: - """ - annotation_reply_feature = AnnotationReplyFeature() - return annotation_reply_feature.query( - app_record=app_record, - message=message, - query=query, - user_id=user_id, - invoke_from=invoke_from, - session=session, - ) - - def moderation_for_inputs( - self, - *, - app_id: str, - tenant_id: str, - app_generate_entity: AppGenerateEntity, - inputs: Mapping[str, Any], - query: str | None = None, - message_id: str, - ) -> tuple[bool, Mapping[str, Any], str]: - """ - Process sensitive_word_avoidance. - :param app_id: app id - :param tenant_id: tenant id - :param app_generate_entity: app generate entity - :param inputs: inputs - :param query: query - :param message_id: message id - :return: - """ - moderation_feature = InputModeration() - return moderation_feature.check( - app_id=app_id, - tenant_id=tenant_id, - app_config=app_generate_entity.app_config, - inputs=dict(inputs), - query=query or "", - message_id=message_id, - trace_manager=app_generate_entity.trace_manager, - ) - - def _initialize_conversation_variables(self) -> list[Variable]: - """ - Initialize conversation variables for the current conversation. - - This method: - 1. Loads existing variables from the database - 2. Creates new variables if none exist - 3. Syncs missing variables from the workflow definition - - :return: List of conversation variables ready for use - """ - with create_session() as session, session.begin(): - existing_variables = self._load_existing_conversation_variables(session) - - if not existing_variables: - # First time initialization - create all variables - existing_variables = self._create_all_conversation_variables(session) - else: - # Check and add any missing variables from the workflow - existing_variables = self._sync_missing_conversation_variables(existing_variables, session) - - # Convert to Variable objects for use in the workflow - conversation_variables = [var.to_variable() for var in existing_variables] - - return cast(list[Variable], conversation_variables) - - def _load_existing_conversation_variables(self, session: Session) -> list[ConversationVariable]: - """ - Load existing conversation variables from the database. - - :param session: Database session - :return: List of existing conversation variables - """ - stmt = select(ConversationVariable).where( - ConversationVariable.app_id == self.conversation.app_id, - ConversationVariable.conversation_id == self.conversation.id, - ) - return list(session.scalars(stmt).all()) - - def _create_all_conversation_variables(self, session: Session) -> list[ConversationVariable]: - """ - Create all conversation variables for a new conversation. - - :param session: Database session - :return: List of created conversation variables - """ - new_variables = [ - ConversationVariable.from_variable( - app_id=self.conversation.app_id, conversation_id=self.conversation.id, variable=variable - ) - for variable in self._workflow.conversation_variables - ] - - if new_variables: - session.add_all(new_variables) - - return new_variables - - def _sync_missing_conversation_variables( - self, existing_variables: list[ConversationVariable], session: Session - ) -> list[ConversationVariable]: - """ - Sync missing conversation variables from the workflow definition. - - This handles the case where new variables are added to a workflow - after conversations have already been created. - - :param session: Database session - :param existing_variables: List of existing conversation variables - :return: Updated list including any newly created variables - """ - # Compare stored primary keys. A non-UUID author id is rewritten by - # ConversationVariable.storage_id, so the workflow id itself will not match. - existing_ids = {var.id for var in existing_variables} - workflow_variables = { - ConversationVariable.storage_id(variable): variable for variable in self._workflow.conversation_variables - } - - # Find missing variable IDs - missing_ids = set(workflow_variables.keys()) - existing_ids - - if not missing_ids: - return existing_variables - - # Create missing variables with their default values - new_variables = [ - ConversationVariable.from_variable( - app_id=self.conversation.app_id, - conversation_id=self.conversation.id, - variable=workflow_variables[var_id], - ) - for var_id in missing_ids - ] - - session.add_all(new_variables) - - # Return combined list - return existing_variables + new_variables +__all__ = ["AdvancedChatAppRunner"] diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index 0c402bccf5fbe8..f00fa37616d266 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 @@ -67,8 +67,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__) @@ -339,8 +342,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 c2d8accae1f1e1..608194e8d07970 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -40,13 +40,18 @@ from core.ops.base_trace_instance import application_tracing_hooks 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 AgentKnowledgeSpaceConfig, AgentSoulConfig, AgentSoulToolsConfig +from models.provider_ids import ModelProviderID +from services.agent.knowledge_runtime_config import build_knowledge_fs_layer_config +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_config_aware_soul_mention_resolver, build_config_layer_config, @@ -54,12 +59,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 AgentKnowledgeSpaceConfig, AgentSoulConfig, AgentSoulToolsConfig -from models.provider_ids import ModelProviderID -from services.agent.knowledge_runtime_config import build_knowledge_fs_layer_config -from services.agent.prompt_mentions import expand_prompt_mentions class AgentAppRuntimeRequestBuildError(ValueError): @@ -105,10 +104,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 9f84b7b794e970..4c4e7bce03e96f 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -64,7 +64,6 @@ from core.workflow.nodes.human_input_v2.presentation import build_human_input_v2_pause_reason from core.workflow.nodes.human_input_v2.runtime import PreparedForm 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 ( @@ -84,6 +83,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_generator.py b/api/core/app/apps/pipeline/pipeline_generator.py index 0def3d10ea1569..9e174d01ae028b 100644 --- a/api/core/app/apps/pipeline/pipeline_generator.py +++ b/api/core/app/apps/pipeline/pipeline_generator.py @@ -1,912 +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, UserFrom -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( - user_from=UserFrom.ACCOUNT if isinstance(user, Account) else UserFrom.END_USER, - 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( - user_from=UserFrom.ACCOUNT if isinstance(user, Account) else UserFrom.END_USER, - 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( - user_from=UserFrom.ACCOUNT if isinstance(user, Account) else UserFrom.END_USER, - 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/app/apps/pipeline/pipeline_runner.py b/api/core/app/apps/pipeline/pipeline_runner.py index 098138294ed9a2..5bcacca845477d 100644 --- a/api/core/app/apps/pipeline/pipeline_runner.py +++ b/api/core/app/apps/pipeline/pipeline_runner.py @@ -1,345 +1,6 @@ -import logging -import time -from typing import Protocol, cast, override +"""Compatibility imports for the pipeline execution service runner.""" -from sqlalchemy import select -from sqlalchemy.orm import Session +from models.pipeline_execution import PipelineDocumentStore +from services.workflow.execution.adapters.pipeline.pipeline_runner import PipelineRunner -from core.app.apps.base_app_queue_manager import AppQueueManager -from core.app.apps.pipeline.pipeline_config_manager import PipelineConfig -from core.app.apps.workflow_app_runner import PreparedWorkflowRun, WorkflowBasedAppRunner, WorkflowRunDriver -from core.app.entities.app_invoke_entities import ( - InvokeFrom, - RagPipelineGenerateEntity, - UserFrom, - build_dify_run_context, -) -from core.credit_usage import CreditUsageAppType -from core.db.session_factory import create_session -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.engine_events import EngineEvent, GraphRunFailedEvent -from graphon.enums import WorkflowType -from graphon.graph import Graph -from graphon.runtime import RuntimeState, VariablePool -from graphon.variable_loader import VariableLoader -from graphon.variables.variables import RAGPipelineVariable, RAGPipelineVariableInput -from models.dataset import Dataset, Pipeline -from models.model import EndUser -from models.workflow import Workflow -from services.workflow.execution.contracts import WorkflowRunEntry - -logger = logging.getLogger(__name__) - - -class PipelineDocumentStore(Protocol): - """Knowledge state required by a pipeline run, with explicit session ownership.""" - - def get_pipeline_dataset(self, pipeline: Pipeline, *, session: Session) -> Dataset | None: ... - - def exists(self, *, workspace_id: str, dataset_id: str, document_id: str) -> bool: ... - - def mark_failed(self, *, workspace_id: str, dataset_id: str, document_id: str, error: str) -> None: ... - - -class PipelineRunner(WorkflowBasedAppRunner): - """ - Pipeline Application Runner - """ - - def __init__( - self, - application_generate_entity: RagPipelineGenerateEntity, - queue_manager: AppQueueManager, - variable_loader: VariableLoader, - workflow: Workflow, - system_user_id: str, - documents: PipelineDocumentStore, - workflow_thread_pool_id: str | None = None, - execution_driver: WorkflowRunDriver | None = None, - ) -> None: - """ - :param application_generate_entity: application generate entity - :param queue_manager: application queue manager - :param workflow_thread_pool_id: workflow thread pool id - """ - super().__init__( - queue_manager=queue_manager, - execution_driver=execution_driver, - variable_loader=variable_loader, - app_id=application_generate_entity.app_config.app_id, - ) - self.application_generate_entity = application_generate_entity - self.workflow_thread_pool_id = workflow_thread_pool_id - self._workflow = workflow - self._sys_user_id = system_user_id - self._documents = documents - - def _get_app_id(self) -> str: - return self.application_generate_entity.app_config.app_id - - @override - def prepare(self) -> PreparedWorkflowRun: - """Prepare graph execution and passive application observers.""" - app_config = self.application_generate_entity.app_config - app_config = cast(PipelineConfig, app_config) - invoke_from = self.application_generate_entity.invoke_from - - if self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run: - invoke_from = InvokeFrom.DEBUGGER - - user_from = self._resolve_user_from(invoke_from) - - user_id = None - with create_session() as session: - if invoke_from in {InvokeFrom.WEB_APP, InvokeFrom.SERVICE_API}: - end_user = session.get(EndUser, self.application_generate_entity.user_id) - if end_user: - user_id = end_user.session_id - else: - user_id = self.application_generate_entity.user_id - - pipeline = session.get(Pipeline, app_config.app_id) - if not pipeline or pipeline.tenant_id != app_config.tenant_id: - raise ValueError("Pipeline not found") - - dataset = self._documents.get_pipeline_dataset(pipeline, session=session) - if ( - not dataset - or dataset.tenant_id != pipeline.tenant_id - or dataset.id != self.application_generate_entity.dataset_id - ): - raise ValueError("Pipeline dataset not found") - - document_id = self.application_generate_entity.document_id - original_document_id = self.application_generate_entity.original_document_id - dataset_workspace_id = dataset.tenant_id - dataset_id = dataset.id - if document_id and not self._documents.exists( - workspace_id=dataset_workspace_id, - dataset_id=dataset_id, - document_id=document_id, - ): - raise ValueError("Pipeline document not found") - if original_document_id and original_document_id != document_id: - if not self._documents.exists( - workspace_id=dataset_workspace_id, - dataset_id=dataset_id, - document_id=original_document_id, - ): - raise ValueError("Pipeline original document not found") - - workflow = self.get_workflow(session=session, pipeline=pipeline, workflow_id=app_config.workflow_id) - if not workflow: - raise ValueError("Workflow not initialized") - - session.expunge(pipeline) - session.expunge(workflow) - - # if only single iteration run is requested - if self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run: - # Handle single iteration or single loop run - graph, variable_pool, graph_runtime_state = self._prepare_single_node_execution( - workflow=workflow, - single_iteration_run=self.application_generate_entity.single_iteration_run, - single_loop_run=self.application_generate_entity.single_loop_run, - user_id=self.application_generate_entity.user_id, - ) - else: - inputs = self.application_generate_entity.inputs - files = self.application_generate_entity.files - - # Create a variable pool. - system_inputs = build_system_variables( - files=files, - user_id=user_id, - app_id=app_config.app_id, - workflow_id=app_config.workflow_id, - workflow_execution_id=self.application_generate_entity.workflow_execution_id, - document_id=self.application_generate_entity.document_id, - original_document_id=self.application_generate_entity.original_document_id, - batch=self.application_generate_entity.batch, - dataset_id=self.application_generate_entity.dataset_id, - datasource_type=self.application_generate_entity.datasource_type, - datasource_info=self.application_generate_entity.datasource_info, - invoke_from=invoke_from.value, - ) - - rag_pipeline_variables = [] - if workflow.rag_pipeline_variables: - for v in workflow.rag_pipeline_variables: - rag_pipeline_variable = RAGPipelineVariable.model_validate(v) - if ( - rag_pipeline_variable.belong_to_node_id - in (self.application_generate_entity.start_node_id, "shared") - ) and rag_pipeline_variable.variable in inputs: - rag_pipeline_variables.append( - RAGPipelineVariableInput( - variable=rag_pipeline_variable, - value=inputs[rag_pipeline_variable.variable], - ) - ) - - variable_pool = VariablePool() - add_variables_to_pool( - variable_pool, - build_bootstrap_variables( - system_variables=system_inputs, - environment_variables=workflow.environment_variables, - rag_pipeline_variables=rag_pipeline_variables, - ), - ) - root_node_id = self.application_generate_entity.start_node_id or get_default_root_node_id( - workflow.graph_dict - ) - add_node_inputs_to_pool(variable_pool, node_id=root_node_id, inputs=inputs) - graph_runtime_state = RuntimeState( - workflow_id=workflow.id, variable_pool=variable_pool, start_at=time.perf_counter() - ) - - # init graph - graph = self._init_rag_pipeline_graph( - graph_runtime_state=graph_runtime_state, - start_node_id=root_node_id, - workflow=workflow, - user_from=user_from, - invoke_from=invoke_from, - ) - - # RUN WORKFLOW - workflow_entry = WorkflowEntry( - graph=graph, - graph_config=workflow.graph_dict, - call_depth=self.application_generate_entity.call_depth, - graph_runtime_state=graph_runtime_state, - ) - - self._queue_manager.graph_runtime_state = graph_runtime_state - - self._dataset_workspace_id = dataset_workspace_id - self._dataset_id = dataset_id - self._document_id = document_id - return PreparedWorkflowRun( - entry=workflow_entry, - application_layers=self._graph_engine_layers, - generate_entity=self.application_generate_entity, - workflow_type=WorkflowType(workflow.type), - workflow_version=workflow.version, - ) - - @override - def handle_event(self, workflow_entry: WorkflowRunEntry, event: EngineEvent, **kwargs) -> None: - self._update_document_status( - event, - workspace_id=self._dataset_workspace_id, - dataset_id=self._dataset_id, - document_id=self._document_id, - ) - super().handle_event(workflow_entry, event, **kwargs) - - def get_workflow(self, session: Session, pipeline: Pipeline, workflow_id: str) -> Workflow | None: - """ - Get workflow - """ - # fetch workflow by workflow_id - workflow = session.scalar( - select(Workflow) - .where(Workflow.tenant_id == pipeline.tenant_id, Workflow.app_id == pipeline.id, Workflow.id == workflow_id) - .limit(1) - ) - - # return workflow - return workflow - - def _init_rag_pipeline_graph( - self, - workflow: Workflow, - graph_runtime_state: RuntimeState, - start_node_id: str | None = None, - user_from: UserFrom = UserFrom.ACCOUNT, - invoke_from: InvokeFrom = InvokeFrom.SERVICE_API, - ) -> Graph: - """ - Init pipeline graph - """ - graph_config = workflow.graph_dict - if "nodes" not in graph_config or "edges" not in graph_config: - raise ValueError("nodes or edges not found in workflow graph") - - if not isinstance(graph_config.get("nodes"), list): - raise ValueError("nodes in workflow graph must be a list") - - if not isinstance(graph_config.get("edges"), list): - raise ValueError("edges in workflow graph must be a list") - # nodes = graph_config.get("nodes", []) - # edges = graph_config.get("edges", []) - # real_run_nodes = [] - # real_edges = [] - # exclude_node_ids = [] - # for node in nodes: - # node_id = node.get("id") - # node_type = node.get("data", {}).get("type", "") - # if node_type == "datasource": - # if start_node_id != node_id: - # exclude_node_ids.append(node_id) - # continue - # real_run_nodes.append(node) - - # for edge in edges: - # if edge.get("source") in exclude_node_ids: - # continue - # real_edges.append(edge) - # graph_config = dict(graph_config) - # graph_config["nodes"] = real_run_nodes - # graph_config["edges"] = real_edges - # init graph - # Create explicit graph init context for Graph.init. - run_context = build_dify_run_context( - tenant_id=workflow.tenant_id, - app_id=self._app_id, - user_id=self.application_generate_entity.user_id, - user_from=user_from, - invoke_from=invoke_from, - app_type=CreditUsageAppType.RAG_PIPELINE, - ) - graph_init_context = DifyGraphInitContext( - execution_driver=self._execution_driver, - workflow_id=workflow.id, - graph_config=graph_config, - run_context=run_context, - call_depth=0, - ) - - node_factory = DifyNodeFactory.from_graph_init_context( - graph_init_context=graph_init_context, - runtime_state=graph_runtime_state, - ) - if start_node_id is None: - start_node_id = get_default_root_node_id(graph_config) - graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id=start_node_id) - - if not graph: - raise ValueError("graph not found in workflow") - - return graph - - def _update_document_status( - self, - event: EngineEvent, - *, - workspace_id: str, - dataset_id: str, - document_id: str | None, - ) -> None: - """Set an owner-bound document to error after a failed graph run, if it exists.""" - if not isinstance(event, GraphRunFailedEvent) or document_id is None: - return - - self._documents.mark_failed( - workspace_id=workspace_id, - dataset_id=dataset_id, - document_id=document_id, - error=event.error or "Unknown error", - ) +__all__ = ["PipelineDocumentStore", "PipelineRunner"] diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index 4a34825bde6a6a..d83867dcc45c82 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/api/core/app/apps/workflow/app_generator.py @@ -1,752 +1,5 @@ -from __future__ import annotations +"""Compatibility import for the workflow generation service adapter.""" -import contextvars -import logging -import threading -import uuid -from collections.abc import Generator, Mapping, Sequence -from typing import TYPE_CHECKING, Any, Literal, overload +from services.workflow.execution.adapters.workflow.app_generator import WorkflowAppGenerator -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 core.app.app_config.features.file_upload.manager import FileUploadConfigManager -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.workflow.active_workflow_tasks import active_workflow_task -from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager -from core.app.apps.workflow.app_queue_manager import WorkflowAppQueueManager -from core.app.apps.workflow.app_runner import WorkflowAppRunner -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, UserFrom, WorkflowAppGenerateEntity -from core.app.entities.task_entities import ( - WorkflowAppBlockingResponse, - WorkflowAppPausedBlockingResponse, - WorkflowAppStreamResponse, -) -from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig -from core.db.session_factory import session_factory -from core.helper.trace_id_helper import ( - extract_external_trace_id_from_args, - extract_parent_trace_context_from_args, - extract_trace_session_id_from_args, -) -from core.ops.ops_trace_manager import TraceQueueManager -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.engine.filter import ResponseStreamFilter -from graphon.engine.layer import Layer -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.account import Account -from models.enums import WorkflowRunTriggeredFrom -from models.model import App, EndUser -from models.workflow import Workflow, WorkflowNodeExecutionTriggeredFrom -from repositories.entities.workflow_execution import WorkflowExecutionIdentity -from services.app.file_input_defaults import grant_file_default_access -from services.workflow_draft_variable_service import DraftVarLoader, WorkflowDraftVariableService - -if TYPE_CHECKING: - from controllers.console.app.workflow import LoopNodeRunPayload - from core.app.apps.workflow_app_runner import WorkflowRunDriver - -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 WorkflowAppGenerator(BaseAppGenerator): - def __init__(self, *, execution_driver: WorkflowRunDriver) -> None: - self._execution_driver = execution_driver - - @staticmethod - def _ensure_snippet_start_node_in_worker(*, session: Session, workflow: Workflow) -> Workflow: - """Re-apply snippet virtual Start injection after worker reloads workflow from DB.""" - if workflow.kind_or_standard != "snippet": - return workflow - - from models.snippet import CustomizedSnippet - from services.snippet_generate_service import SnippetGenerateService - - snippet = session.scalar( - select(CustomizedSnippet).where( - CustomizedSnippet.id == workflow.app_id, - CustomizedSnippet.tenant_id == workflow.tenant_id, - ) - ) - if snippet is None: - return workflow - return SnippetGenerateService.ensure_start_node_for_worker(workflow, snippet) - - @overload - def generate( - self, - *, - execution_identity: WorkflowExecutionIdentity, - app_model: App, - workflow: Workflow, - user: Account | EndUser, - args: Mapping[str, Any], - invoke_from: InvokeFrom, - streaming: Literal[True], - call_depth: int, - workflow_run_id: str | uuid.UUID | None = None, - triggered_from: WorkflowRunTriggeredFrom | None = None, - root_node_id: str | None = None, - graph_engine_layers: Sequence[Layer] = (), - allow_human_input: bool = True, - pause_state_config: PauseStateLayerConfig | None = None, - ) -> Generator[Mapping[str, Any] | str]: ... - - @overload - def generate( - self, - *, - execution_identity: WorkflowExecutionIdentity, - app_model: App, - workflow: Workflow, - user: Account | EndUser, - args: Mapping[str, Any], - invoke_from: InvokeFrom, - streaming: Literal[False], - call_depth: int, - workflow_run_id: str | uuid.UUID | None = None, - triggered_from: WorkflowRunTriggeredFrom | None = None, - root_node_id: str | None = None, - graph_engine_layers: Sequence[Layer] = (), - allow_human_input: bool = True, - pause_state_config: PauseStateLayerConfig | None = None, - ) -> Mapping[str, Any]: ... - - @overload - def generate( - self, - *, - execution_identity: WorkflowExecutionIdentity, - app_model: App, - workflow: Workflow, - user: Account | EndUser, - args: Mapping[str, Any], - invoke_from: InvokeFrom, - streaming: bool, - call_depth: int, - workflow_run_id: str | uuid.UUID | None = None, - triggered_from: WorkflowRunTriggeredFrom | None = None, - root_node_id: str | None = None, - graph_engine_layers: Sequence[Layer] = (), - allow_human_input: bool = True, - pause_state_config: PauseStateLayerConfig | None = None, - ) -> Mapping[str, Any] | Generator[Mapping[str, Any] | str]: ... - - def generate( - self, - *, - execution_identity: WorkflowExecutionIdentity, - app_model: App, - workflow: Workflow, - user: Account | EndUser, - args: Mapping[str, Any], - invoke_from: InvokeFrom, - streaming: bool = True, - call_depth: int = 0, - workflow_run_id: str | uuid.UUID | None = None, - triggered_from: WorkflowRunTriggeredFrom | None = None, - root_node_id: str | None = None, - graph_engine_layers: Sequence[Layer] = (), - allow_human_input: bool = True, - pause_state_config: PauseStateLayerConfig | None = None, - ) -> Mapping[str, Any] | Generator[Mapping[str, Any] | str]: - with self._bind_file_access_scope(tenant_id=app_model.tenant_id, user=user, invoke_from=invoke_from): - files: Sequence[Mapping[str, Any]] = args.get("files") or [] - - # 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. - file_extra_config = FileUploadConfigManager.convert(workflow.features_dict, is_vision=False) - system_files = file_factory.build_from_mappings( - mappings=files, - tenant_id=app_model.tenant_id, - config=file_extra_config, - strict_type_validation=invoke_from == InvokeFrom.SERVICE_API, - access_controller=self._file_access_controller, - ) - - # convert to app config - app_config = WorkflowAppConfigManager.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, - ) - - inputs: Mapping[str, Any] = args["inputs"] - - extras = { - **extract_external_trace_id_from_args(args), - **extract_parent_trace_context_from_args(args), - **extract_trace_session_id_from_args(args), - } - workflow_run_id = str(workflow_run_id or uuid.uuid4()) - root_node_id = root_node_id or get_default_root_node_id(workflow.graph_dict) - root_node_config = workflow.get_node_config_by_id(root_node_id) - root_node_type = workflow.get_node_type_from_node_config(root_node_config) - # Trigger inputs are already adapted event data, not Start form fields. - if not is_trigger_node_type(root_node_type): - inputs = self._prepare_user_inputs( - user_inputs=inputs, - variables=app_config.variables, - tenant_id=app_model.tenant_id, - strict_type_validation=invoke_from == InvokeFrom.SERVICE_API, - ) - # init application generate entity - application_generate_entity = WorkflowAppGenerateEntity( - task_id=str(uuid.uuid4()), - app_config=app_config, - file_upload_config=file_extra_config, - inputs=inputs, - files=list(system_files), - user_id=execution_identity.user_id, - user_from=execution_identity.user_from, - stream=streaming, - invoke_from=invoke_from, - allow_human_input=allow_human_input, - call_depth=call_depth, - trace_manager=trace_manager, - workflow_execution_id=workflow_run_id, - extras=extras, - ) - - 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 triggered_from is not None: - # Use explicitly provided triggered_from (for async triggers) - workflow_triggered_from = triggered_from - elif 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( - app_model=app_model, - workflow=workflow, - 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, - root_node_id=root_node_id, - graph_engine_layers=graph_engine_layers, - pause_state_config=pause_state_config, - ) - - def resume( - self, - *, - app_model: App, - workflow: Workflow, - user: Account | EndUser, - application_generate_entity: WorkflowAppGenerateEntity, - graph_runtime_state: RuntimeState, - workflow_execution_repository: WorkflowExecutionRepository, - workflow_node_execution_repository: WorkflowNodeExecutionRepository, - graph_engine_layers: Sequence[Layer] = (), - pause_state_config: PauseStateLayerConfig | None = None, - variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER, - response_stream_filter: ResponseStreamFilter | None = None, - ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any]]: - """ - Resume a paused workflow execution using the persisted runtime state. - - ``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( - app_model=app_model, - workflow=workflow, - user=user, - application_generate_entity=application_generate_entity, - invoke_from=application_generate_entity.invoke_from, - workflow_execution_repository=workflow_execution_repository, - workflow_node_execution_repository=workflow_node_execution_repository, - streaming=application_generate_entity.stream, - variable_loader=variable_loader, - graph_engine_layers=graph_engine_layers, - graph_runtime_state=graph_runtime_state, - pause_state_config=pause_state_config, - response_stream_filter=response_stream_filter, - ) - - def _generate( - self, - *, - app_model: App, - workflow: Workflow, - user: Account | EndUser, - application_generate_entity: WorkflowAppGenerateEntity, - invoke_from: InvokeFrom, - workflow_execution_repository: WorkflowExecutionRepository, - workflow_node_execution_repository: WorkflowNodeExecutionRepository, - streaming: bool = True, - variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER, - root_node_id: str | None = None, - graph_engine_layers: Sequence[Layer] = (), - graph_runtime_state: RuntimeState | None = None, - pause_state_config: PauseStateLayerConfig | None = None, - response_stream_filter: ResponseStreamFilter | None = None, - ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any]]: - """ - Generate App response. - - :param app_model: App - :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 - """ - with self._bind_file_access_scope( - tenant_id=application_generate_entity.app_config.tenant_id, - user=user, - invoke_from=invoke_from, - ): - grant_file_default_access(application_generate_entity.app_config.variables) - # init queue manager - queue_manager = WorkflowAppQueueManager( - task_id=application_generate_entity.task_id, - user_id=application_generate_entity.user_id, - invoke_from=application_generate_entity.invoke_from, - app_mode=app_model.mode, - ) - - # new thread with request context and contextvars - context = contextvars.copy_context() - - # release database connection, because the following new thread operations may take a long time - db.session.close() - - 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, - "context": context, - "variable_loader": variable_loader, - "root_node_id": root_node_id, - "workflow_execution_repository": workflow_execution_repository, - "workflow_node_execution_repository": workflow_node_execution_repository, - "graph_engine_layers": graph_engine_layers, - "pause_state_config": pause_state_config, - "graph_runtime_state": graph_runtime_state, - "response_stream_filter": response_stream_filter, - }, - ) - - worker_thread.start() - - draft_var_saver_factory = self._get_draft_var_saver_factory( - invoke_from, - user, - tenant_id=app_model.tenant_id, - ) - - try: - response = self._handle_response( - application_generate_entity=application_generate_entity, - workflow=workflow, - queue_manager=queue_manager, - user=user, - draft_var_saver_factory=draft_var_saver_factory, - stream=streaming, - ) - 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, - app_model: App, - workflow: Workflow, - node_id: str, - user: Account | EndUser, - args: Mapping[str, Any], - streaming: bool = True, - *, - execution_identity: WorkflowExecutionIdentity, - 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 = WorkflowAppConfigManager.get_app_config(app_model=app_model, workflow=workflow) - - # init application generate entity - application_generate_entity = WorkflowAppGenerateEntity( - task_id=str(uuid.uuid4()), - app_config=app_config, - inputs={}, - files=[], - user_id=execution_identity.user_id, - user_from=execution_identity.user_from, - stream=streaming, - invoke_from=InvokeFrom.DEBUGGER, - extras={ - "auto_generate_conversation_name": False, - **_extract_trace_session_id_from_debug_args(args), - }, - single_iteration_run=WorkflowAppGenerateEntity.SingleIterationRunEntity( - 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 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, - ) - 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( - app_model=app_model, - 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, - streaming=streaming, - variable_loader=var_loader, - pause_state_config=None, - ) - - def single_loop_generate( - self, - app_model: App, - workflow: Workflow, - node_id: str, - user: Account | EndUser, - args: LoopNodeRunPayload, - streaming: bool = True, - *, - execution_identity: WorkflowExecutionIdentity, - 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.inputs is None: - raise ValueError("inputs is required") - - # convert to app config - app_config = WorkflowAppConfigManager.get_app_config(app_model=app_model, workflow=workflow) - - # init application generate entity - application_generate_entity = WorkflowAppGenerateEntity( - task_id=str(uuid.uuid4()), - app_config=app_config, - inputs={}, - files=[], - user_id=execution_identity.user_id, - user_from=execution_identity.user_from, - stream=streaming, - invoke_from=InvokeFrom.DEBUGGER, - extras={ - "auto_generate_conversation_name": False, - **_extract_trace_session_id_from_debug_args(args), - }, - single_loop_run=WorkflowAppGenerateEntity.SingleLoopRunEntity(node_id=node_id, inputs=args.inputs or {}), - workflow_execution_id=str(uuid.uuid4()), - ) - 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, - ) - 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( - app_model=app_model, - 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, - streaming=streaming, - variable_loader=var_loader, - pause_state_config=None, - ) - - def _generate_worker( - self, - flask_app: Flask, - application_generate_entity: WorkflowAppGenerateEntity, - queue_manager: AppQueueManager, - context: contextvars.Context, - variable_loader: VariableLoader, - workflow_execution_repository: WorkflowExecutionRepository, - workflow_node_execution_repository: WorkflowNodeExecutionRepository, - root_node_id: str | None = None, - graph_engine_layers: Sequence[Layer] = (), - graph_runtime_state: RuntimeState | None = None, - response_stream_filter: ResponseStreamFilter | None = None, - pause_state_config: PauseStateLayerConfig | 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): - with session_factory.create_session() 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") - - workflow = self._ensure_snippet_start_node_in_worker(session=session, workflow=workflow) - if graph_runtime_state is not None: - self._restore_workflow_run_graph( - session=session, - workflow=workflow, - workflow_run_id=application_generate_entity.workflow_execution_id, - ) - - user_from = application_generate_entity.user_from - if user_from == UserFrom.END_USER: - end_user = session.scalar( - select(EndUser).where( - EndUser.tenant_id == application_generate_entity.app_config.tenant_id, - EndUser.id == application_generate_entity.user_id, - ) - ) - system_user_id = end_user.session_id if end_user else "" - else: - system_user_id = application_generate_entity.user_id - - runner = WorkflowAppRunner( - application_generate_entity=application_generate_entity, - execution_driver=self._execution_driver, - queue_manager=queue_manager, - variable_loader=variable_loader, - workflow=workflow, - system_user_id=system_user_id, - root_node_id=root_node_id, - graph_engine_layers=graph_engine_layers, - 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: - logger.warning("Task stopped", exc_info=True) - 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: WorkflowAppGenerateEntity, - 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 - :param workflow_node_execution_repository: optional repository for workflow node execution - :return: - """ - # init generate task pipeline - generate_task_pipeline = WorkflowAppGenerateTaskPipeline( - application_generate_entity=application_generate_entity, - workflow=workflow, - queue_manager=queue_manager, - user=user, - draft_var_saver_factory=draft_var_saver_factory, - stream=stream, - ) - - 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: %s", application_generate_entity.task_id - ) - raise e +__all__ = ["WorkflowAppGenerator"] diff --git a/api/core/app/apps/workflow/app_runner.py b/api/core/app/apps/workflow/app_runner.py index b2390ea19b156c..d94196e23dc10b 100644 --- a/api/core/app/apps/workflow/app_runner.py +++ b/api/core/app/apps/workflow/app_runner.py @@ -1,193 +1,5 @@ -import logging -import time -from collections.abc import Sequence -from typing import cast, override +"""Compatibility imports for the workflow execution service runner.""" -from core.app.apps.base_app_queue_manager import AppQueueManager -from core.app.apps.execution_coordinator import app_task_command_channel_key -from core.app.apps.workflow.app_config_manager import WorkflowAppConfig -from core.app.apps.workflow.command_channels import ( - CelerySignalCommandChannel, - CombinedCommandChannel, - StopFlagCommandChannel, -) -from core.app.apps.workflow.stop_aware_ready_queue import attach_stop_aware_ready_queue -from core.app.apps.workflow_app_runner import PreparedWorkflowRun, WorkflowBasedAppRunner, WorkflowRunDriver -from core.app.entities.app_invoke_entities import ( - InvokeFrom, - WorkflowAppGenerateEntity, - get_credit_usage_app_type, -) -from core.workflow.node_factory import get_default_root_node_id -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.workflow_warm_shutdown import WORKFLOW_WARM_SHUTDOWN_ABORT_REASON, celery_warm_shutdown_started -from graphon.engine.command import RedisChannel -from graphon.engine.filter import ResponseStreamFilter -from graphon.engine.layer import Layer -from graphon.enums import WorkflowType -from graphon.runtime import RuntimeState, VariablePool -from graphon.variable_loader import VariableLoader -from libs.datetime_utils import naive_utc_now -from models.workflow import Workflow +from services.workflow.execution.adapters.workflow.app_runner import WorkflowAppRunner, WorkflowExecutionCancellation -logger = logging.getLogger(__name__) - - -class WorkflowAppRunner(WorkflowBasedAppRunner): - """ - Workflow Application Runner - """ - - def __init__( - self, - *, - application_generate_entity: WorkflowAppGenerateEntity, - queue_manager: AppQueueManager, - variable_loader: VariableLoader, - workflow: Workflow, - system_user_id: str, - root_node_id: str | None = None, - execution_driver: WorkflowRunDriver | None = None, - graph_engine_layers: Sequence[Layer] = (), - graph_runtime_state: RuntimeState | None = None, - response_stream_filter: ResponseStreamFilter | None = None, - ): - super().__init__( - queue_manager=queue_manager, - execution_driver=execution_driver, - variable_loader=variable_loader, - app_id=application_generate_entity.app_config.app_id, - graph_engine_layers=graph_engine_layers, - ) - self.application_generate_entity = application_generate_entity - self._workflow = workflow - self._sys_user_id = system_user_id - self._root_node_id = root_node_id - self._resume_graph_runtime_state = graph_runtime_state - self._response_stream_filter = response_stream_filter - - @override - def prepare(self) -> PreparedWorkflowRun | None: - """Prepare graph execution and passive application observers.""" - app_config = self.application_generate_entity.app_config - app_config = cast(WorkflowAppConfig, app_config) - invoke_from = self.application_generate_entity.invoke_from - # if only single iteration or single loop run is requested - if self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run: - invoke_from = InvokeFrom.DEBUGGER - user_from = self.application_generate_entity.user_from - - resume_state = self._resume_graph_runtime_state - - if resume_state is not None: - graph_runtime_state = resume_state - variable_pool = graph_runtime_state.variable_pool - graph = self._init_graph( - graph_config=self._workflow.graph_dict, - graph_runtime_state=graph_runtime_state, - workflow_id=self._workflow.id, - tenant_id=self._workflow.tenant_id, - user_id=self.application_generate_entity.user_id, - user_from=user_from, - invoke_from=invoke_from, - root_node_id=self._root_node_id, - app_type=get_credit_usage_app_type(app_config.app_mode), - trace_session_id=self.application_generate_entity.extras.get("trace_session_id"), - call_depth=self.application_generate_entity.call_depth, - ) - elif self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run: - graph, variable_pool, graph_runtime_state = self._prepare_single_node_execution( - workflow=self._workflow, - single_iteration_run=self.application_generate_entity.single_iteration_run, - single_loop_run=self.application_generate_entity.single_loop_run, - user_id=self.application_generate_entity.user_id, - app_type=get_credit_usage_app_type(app_config.app_mode), - trace_session_id=self.application_generate_entity.extras.get("trace_session_id"), - ) - else: - inputs = self.application_generate_entity.inputs - - # Create a variable pool. - system_inputs = build_system_variables( - files=self.application_generate_entity.files, - user_id=self._sys_user_id, - app_id=app_config.app_id, - timestamp=int(naive_utc_now().timestamp()), - workflow_id=app_config.workflow_id, - workflow_execution_id=self.application_generate_entity.workflow_execution_id, - ) - variable_pool = VariablePool() - add_variables_to_pool( - variable_pool, - build_bootstrap_variables( - system_variables=system_inputs, - environment_variables=self._workflow.environment_variables, - ), - ) - root_node_id = self._root_node_id or get_default_root_node_id(self._workflow.graph_dict) - add_node_inputs_to_pool( - variable_pool, - node_id=root_node_id, - inputs=inputs, - aliases=get_compatible_start_aliases( - workflow_kind=getattr(self._workflow, "kind_or_standard", None), - root_node_id=root_node_id, - ), - ) - - graph_runtime_state = RuntimeState( - workflow_id=self._workflow.id, variable_pool=variable_pool, start_at=time.perf_counter() - ) - graph = self._init_graph( - graph_config=self._workflow.graph_dict, - graph_runtime_state=graph_runtime_state, - workflow_id=self._workflow.id, - tenant_id=self._workflow.tenant_id, - user_id=self.application_generate_entity.user_id, - user_from=user_from, - invoke_from=invoke_from, - root_node_id=root_node_id, - app_type=get_credit_usage_app_type(app_config.app_mode), - trace_session_id=self.application_generate_entity.extras.get("trace_session_id"), - call_depth=self.application_generate_entity.call_depth, - ) - - # RUN WORKFLOW - # Create Redis command channel for this workflow execution - task_id = self.application_generate_entity.task_id - channel_key = app_task_command_channel_key(task_id) - celery_signal_channel = CelerySignalCommandChannel( - shutdown_state_getter=celery_warm_shutdown_started, - abort_reason=WORKFLOW_WARM_SHUTDOWN_ABORT_REASON, - ) - attach_stop_aware_ready_queue(graph_runtime_state, task_id=task_id) - command_channel = CombinedCommandChannel( - ( - RedisChannel(redis_client, channel_key), - StopFlagCommandChannel(task_id=task_id), - celery_signal_channel, - ) - ) - - self._queue_manager.graph_runtime_state = graph_runtime_state - - workflow_entry = WorkflowEntry( - graph=graph, - graph_config=self._workflow.graph_dict, - call_depth=self.application_generate_entity.call_depth, - graph_runtime_state=graph_runtime_state, - command_channel=command_channel, - response_stream_filter=self._response_stream_filter, - ) - - return PreparedWorkflowRun( - entry=workflow_entry, - application_layers=self._graph_engine_layers, - generate_entity=self.application_generate_entity, - workflow_type=WorkflowType(self._workflow.type), - workflow_version=self._workflow.version, - ) +__all__ = ["WorkflowAppRunner", "WorkflowExecutionCancellation"] diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index e9e844b6b4075b..4712f3fd0f8b2d 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -1,938 +1,11 @@ -import logging -import time -from collections.abc import Mapping, Sequence -from typing import Any, cast +"""Compatibility aliases for workflow execution service contracts.""" -from pydantic import BaseModel, ConfigDict, ValidationError +from services.workflow.execution.contracts import PreparedWorkflowRun, WorkflowRunDriver, WorkflowRunRunner -from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom -from core.app.entities.agent_strategy import AgentStrategyInfo -from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_dify_run_context -from core.app.entities.queue_entities import ( - AppQueueEvent, - NodeExecutionSnapshot, - QueueAgentLogEvent, - QueueHumanInputFormFilledEvent, - QueueHumanInputFormTimeoutEvent, - QueueIterationCompletedEvent, - QueueIterationNextEvent, - QueueIterationStartEvent, - QueueLoopCompletedEvent, - QueueLoopNextEvent, - QueueLoopStartEvent, - QueueNodeExceptionEvent, - QueueNodeFailedEvent, - QueueNodeRetryEvent, - QueueNodeStartedEvent, - QueueNodeSucceededEvent, - QueueReasoningChunkEvent, - QueueRetrieverResourcesEvent, - QueueStopEvent, - QueueTextChunkEvent, - QueueWorkflowFailedEvent, - QueueWorkflowPartialSuccessEvent, - QueueWorkflowPausedEvent, - QueueWorkflowStartedEvent, - QueueWorkflowSucceededEvent, -) -from core.credit_usage import CreditUsageAppType -from core.rag.entities import RetrievalSourceMetadata -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 resolve_human_input_node_id -from core.workflow.nodes.human_input.callback import DifyHITLCallback -from core.workflow.nodes.human_input.pause_reason import HumanInputRequired, PauseReason -from core.workflow.nodes.human_input_v2.events import ( - NodeRunHumanInputV2FormFilledEvent, - NodeRunHumanInputV2FormTimeoutEvent, -) -from core.workflow.nodes.human_input_v2.runtime import PreparedForm -from core.workflow.system_variables import ( - build_bootstrap_variables, - 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_variables_to_pool -from core.workflow.workflow_entry import WorkflowEntry -from core.workflow.workflow_run_outputs import project_node_outputs_for_workflow_run -from enums.human_input import HumanInputFormStatus -from graphon.engine.layer import Layer -from graphon.engine_events import ( - EngineEvent, - GraphRunAbortedEvent, - GraphRunFailedEvent, - GraphRunPartialSucceededEvent, - GraphRunPausedEvent, - GraphRunStartedEvent, - GraphRunSucceededEvent, - NodeEvent, - NodeRunExceptionEvent, - NodeRunFailedEvent, - NodeRunHumanInputFormFilledEvent, - NodeRunHumanInputFormTimeoutEvent, - NodeRunIterationFailedEvent, - NodeRunIterationNextEvent, - NodeRunIterationStartedEvent, - NodeRunIterationSucceededEvent, - NodeRunLoopFailedEvent, - NodeRunLoopNextEvent, - NodeRunLoopStartedEvent, - NodeRunLoopSucceededEvent, - NodeRunReasoningChunkEvent, - NodeRunRetrieverResourceEvent, - NodeRunRetryEvent, - NodeRunStartedEvent, - NodeRunStreamChunkEvent, - NodeRunSucceededEvent, -) -from graphon.entities.base_node_data import BaseNodeData -from graphon.entities.graph_config import NodeConfigDict -from graphon.entities.pause_reason import HitlRequired -from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey -from graphon.graph import Graph -from graphon.graph.scoping import resolve_container_id -from graphon.runtime import RuntimeState, VariablePool -from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_into_variable_pool -from graphon.variables.factory import build_segment -from models.human_input_contracts import HumanInputFormRecord -from models.workflow import Workflow -from services.human_input.ports import HumanInputFormReader -from services.workflow.execution.contracts import ( - PreparedWorkflowRun, - WorkflowRunDriver, - WorkflowRunEntry, -) -from services.workflow_node_variables import load_additional_node_variables -from tasks.mail_human_input_delivery_task import dispatch_human_input_form_delivery_task +WorkflowBasedAppRunner = WorkflowRunRunner -logger = logging.getLogger(__name__) - - -class _WorkflowGraphNodeData(BaseNodeData): - """Node data fields the runner needs before concrete node validation.""" - - start_node_id: str | None = None - iteration_id: str | None = None - loop_id: str | None = None - - -class _WorkflowGraphNodeConfig(BaseModel): - """Top-level node wrapper used for runner graph filtering.""" - - id: str - data: _WorkflowGraphNodeData - - model_config = ConfigDict(extra="allow", strict=True) - - def to_graph_node_config(self) -> dict[str, Any]: - return { - **self.model_dump(mode="python", exclude={"data"}, exclude_unset=True), - "data": self.data.model_dump(mode="python", exclude_unset=True), - } - - def to_typed_node_config(self) -> NodeConfigDict: - return cast(NodeConfigDict, {**self.model_dump(mode="python", exclude={"data"}), "data": self.data}) - - -class _WorkflowGraphEdgeConfig(BaseModel): - """Top-level edge wrapper used for selecting a single-node subgraph.""" - - source: str | None = None - target: str | None = None - - model_config = ConfigDict(extra="allow", strict=True) - - -class _WorkflowGraphConfig(BaseModel): - """Validated graph config boundary for runner methods. - - Workflow graph payloads are persisted JSON mappings with extra metadata used - by graphon, so validation is intentionally limited to the top-level graph - shape this runner needs before passing the mapping onward. - """ - - nodes: list[_WorkflowGraphNodeConfig] - edges: list[_WorkflowGraphEdgeConfig] - - model_config = ConfigDict(extra="allow", strict=True) - - def to_graph_config(self) -> dict[str, Any]: - return self.model_dump(mode="python", exclude_unset=True) - - -def _merge_container_metadata( - lifecycle_metadata: Mapping[str, Any], node_metadata: Mapping[str, Any] -) -> dict[str, Any]: - # Keep lifecycle details and ancestor ownership together, preserving the - # existing node-result precedence when both sources contain the same key. - return {**lifecycle_metadata, **node_metadata} - - -class WorkflowBasedAppRunner: - def __init__( - self, - *, - queue_manager: AppQueueManager, - variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER, - app_id: str, - graph_engine_layers: Sequence[Layer] = (), - execution_driver: WorkflowRunDriver | None = None, - human_form_reader: HumanInputFormReader | None = None, - ): - self._queue_manager = queue_manager - self._variable_loader = variable_loader - self._app_id = app_id - self._graph_engine_layers = graph_engine_layers - self._execution_driver = execution_driver - self._human_form_reader = human_form_reader - - @property - def human_form_reader(self) -> HumanInputFormReader: - """Return the injected reader, with a legacy fallback during runner migration.""" - if self._human_form_reader is None: - from core.repositories.human_input_repository import HumanInputFormSubmissionRepository - - self._human_form_reader = cast(HumanInputFormReader, HumanInputFormSubmissionRepository()) - return self._human_form_reader - - def prepare(self) -> PreparedWorkflowRun | None: - raise NotImplementedError - - @staticmethod - def _resolve_user_from(invoke_from: InvokeFrom) -> UserFrom: - if invoke_from.runs_as_account(): - return UserFrom.ACCOUNT - return UserFrom.END_USER - - def _init_graph( - self, - graph_config: Mapping[str, Any], - graph_runtime_state: RuntimeState, - user_from: UserFrom, - invoke_from: InvokeFrom, - call_depth: int, - workflow_id: str = "", - tenant_id: str = "", - user_id: str = "", - root_node_id: str | None = None, - app_type: CreditUsageAppType | None = None, - trace_session_id: str | None = None, - ) -> Graph: - """ - Init graph - """ - graph_config = _WorkflowGraphConfig.model_validate(graph_config).to_graph_config() - - # Create explicit graph init context for Graph.init. - run_context = build_dify_run_context( - tenant_id=tenant_id or "", - app_id=self._app_id, - user_id=user_id, - user_from=user_from, - invoke_from=invoke_from, - app_type=app_type, - trace_session_id=trace_session_id, - ) - graph_init_context = DifyGraphInitContext( - execution_driver=self._execution_driver, - workflow_id=workflow_id, - graph_config=graph_config, - run_context=run_context, - call_depth=call_depth, - ) - - # Use the provided graph_runtime_state for consistent state management - - node_factory = DifyNodeFactory.from_graph_init_context( - graph_init_context=graph_init_context, - runtime_state=graph_runtime_state, - ) - - if root_node_id is None: - root_node_id = get_default_root_node_id(graph_config) - - # init graph - graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id=root_node_id) - - if not graph: - raise ValueError("graph not found in workflow") - - return graph - - def _prepare_single_node_execution( - self, - workflow: Workflow, - single_iteration_run: Any | None = None, - single_loop_run: Any | None = None, - *, - user_id: str, - app_type: CreditUsageAppType | None = None, - trace_session_id: str | None = None, - ) -> tuple[Graph, VariablePool, RuntimeState]: - """ - Prepare graph, variable pool, and runtime state for single node execution - (either single iteration or single loop). - - Args: - workflow: The workflow instance - single_iteration_run: SingleIterationRunEntity if running single iteration, None otherwise - single_loop_run: SingleLoopRunEntity if running single loop, None otherwise - - Returns: - A tuple containing (graph, variable_pool, graph_runtime_state) - - Raises: - ValueError: If neither single_iteration_run nor single_loop_run is specified - """ - # Create initial runtime state with variable pool containing environment variables - variable_pool = VariablePool() - add_variables_to_pool( - variable_pool, - build_bootstrap_variables( - system_variables=default_system_variables(), - environment_variables=workflow.environment_variables, - ), - ) - graph_runtime_state = RuntimeState(workflow_id=workflow.id, variable_pool=variable_pool, start_at=time.time()) - - # Determine which type of single node execution and get graph/variable_pool - if single_iteration_run: - graph, variable_pool = self._get_graph_and_variable_pool_for_single_node_run( - workflow=workflow, - node_id=single_iteration_run.node_id, - user_inputs=dict(single_iteration_run.inputs), - graph_runtime_state=graph_runtime_state, - node_type_label="iteration", - user_id=user_id, - app_type=app_type, - trace_session_id=trace_session_id, - ) - elif single_loop_run: - graph, variable_pool = self._get_graph_and_variable_pool_for_single_node_run( - workflow=workflow, - node_id=single_loop_run.node_id, - user_inputs=dict(single_loop_run.inputs), - graph_runtime_state=graph_runtime_state, - node_type_label="loop", - user_id=user_id, - app_type=app_type, - trace_session_id=trace_session_id, - ) - else: - raise ValueError("Neither single_iteration_run nor single_loop_run is specified") - - # Return the graph, variable_pool, and the same graph_runtime_state used during graph creation - # This ensures all nodes in the graph reference the same RuntimeState instance - return graph, variable_pool, graph_runtime_state - - def _get_graph_and_variable_pool_for_single_node_run( - self, - workflow: Workflow, - node_id: str, - user_inputs: dict[str, Any], - graph_runtime_state: RuntimeState, - node_type_label: str = "node", # 'iteration' or 'loop' for error messages - *, - user_id: str = "", - app_type: CreditUsageAppType | None = None, - trace_session_id: str | None = None, - ) -> tuple[Graph, VariablePool]: - """ - Get graph and variable pool for single node execution (iteration or loop). - The workflow graph and user inputs are treated as caller-owned data; the - narrowed graph config used for execution is built locally. - - Args: - workflow: The workflow instance - node_id: The node ID to execute - user_inputs: User inputs for the node - graph_runtime_state: The graph runtime state - node_type_label: Label for error messages ('iteration' or 'loop') - - Returns: - A tuple containing (graph, variable_pool) - """ - # fetch workflow graph - source_graph_config = _WorkflowGraphConfig.model_validate(workflow.graph_dict) - - node_user_inputs = dict(user_inputs) - - graph_config = source_graph_config.to_graph_config() - - # filter nodes only in the specified node type (iteration or loop) - all_node_configs = graph_config["nodes"] - nodes_by_id = {node["id"]: node for node in all_node_configs} - container_ids = {node["id"]: resolve_container_id(node, nodes_by_id=nodes_by_id) for node in all_node_configs} - node_ids = {node_id} - while True: - child_node_ids = {node["id"] for node in all_node_configs if container_ids[node["id"]] in node_ids} - child_node_ids -= node_ids - if not child_node_ids: - break - node_ids.update(child_node_ids) - # Preserve ownership resolved with the complete ancestry before removing it - # from the debug subtree. Nested legacy IDs can otherwise become ambiguous. - selected_node_configs = [] - for node in all_node_configs: - if node["id"] not in node_ids: - continue - selected_node = dict(node) - selected_node["data"] = dict(node["data"]) - selected_node["data"]["container_id"] = container_ids[node["id"]] - selected_node_configs.append(_WorkflowGraphNodeConfig.model_validate(selected_node)) - - # filter edges only in the specified node type - selected_edge_configs = [ - edge - for edge in source_graph_config.edges - if (edge.source is None or edge.source in node_ids) and (edge.target is None or edge.target in node_ids) - ] - - node_configs = [node.to_graph_node_config() for node in selected_node_configs] - edge_configs = [edge.model_dump(mode="python", exclude_unset=True) for edge in selected_edge_configs] - graph_config = { - **graph_config, - "nodes": node_configs, - "edges": edge_configs, - } - - typed_node_configs = selected_node_configs - - # Create explicit graph init context for Graph.init. - run_context = build_dify_run_context( - tenant_id=workflow.tenant_id, - app_id=self._app_id, - user_id=user_id, - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - app_type=app_type, - trace_session_id=trace_session_id, - ) - graph_init_context = DifyGraphInitContext( - execution_driver=self._execution_driver, - workflow_id=workflow.id, - graph_config=graph_config, - run_context=run_context, - call_depth=0, - ) - - node_factory = DifyNodeFactory.from_graph_init_context( - graph_init_context=graph_init_context, - runtime_state=graph_runtime_state, - ) - - target_node_config = None - for node in typed_node_configs: - if node.id == node_id: - target_node_config = node - break - - if not target_node_config: - raise ValueError(f"{node_type_label} node id not found in workflow graph") - - # Get node class - node_type = target_node_config.data.type - node_version = str(target_node_config.data.version) - node_cls = resolve_workflow_node_class(node_type=node_type, node_version=node_version) - - # Use the variable pool from graph_runtime_state instead of creating a new one - variable_pool = graph_runtime_state.variable_pool - - preload_node_creation_variables( - variable_loader=self._variable_loader, - variable_pool=variable_pool, - selectors=[ - selector - for node_config in typed_node_configs - for selector in get_node_creation_preload_selectors( - node_type=node_config.data.type, - node_data=node_config.data, - ) - ], - ) - - try: - variable_mapping = node_cls.extract_variable_selector_to_variable_mapping( - graph_config=graph_config, config=target_node_config.to_typed_node_config() - ) - except NotImplementedError: - variable_mapping = dict[str, Sequence[str]]() - variable_mapping = inject_default_system_variable_mappings( - node_id=target_node_config.id, - node_type=node_type, - node_data=target_node_config.data, - variable_mapping=variable_mapping, - ) - - load_into_variable_pool( - variable_loader=self._variable_loader, - variable_pool=variable_pool, - variable_mapping=variable_mapping, - user_inputs=node_user_inputs, - ) - variable_mapping = load_additional_node_variables( - node_config=target_node_config.to_typed_node_config(), - graph_config=graph_config, - variable_mapping=variable_mapping, - variable_loader=self._variable_loader, - variable_pool=variable_pool, - user_inputs=node_user_inputs, - ) - - WorkflowEntry.mapping_user_inputs_to_variable_pool( - variable_mapping=variable_mapping, - user_inputs=node_user_inputs, - variable_pool=variable_pool, - tenant_id=workflow.tenant_id, - ) - - # init graph after constructor-time context has been loaded - graph = Graph.init( - graph_config=graph_config, - node_factory=node_factory, - root_node_id=node_id, - container_id=container_ids[node_id], - skip_validation=True, - ) - - if not graph: - raise ValueError("graph not found in workflow") - - return graph, variable_pool - - @staticmethod - def _build_agent_strategy_info(event: NodeRunStartedEvent) -> AgentStrategyInfo | None: - raw_agent_strategy = event.extras.get("agent_strategy") - if raw_agent_strategy is None: - return None - - try: - return AgentStrategyInfo.model_validate(raw_agent_strategy) - except ValidationError: - logger.warning("Invalid agent strategy payload for node %s", event.node_id, exc_info=True) - return None - - def publish_human_input_results( - self, - workflow_entry: WorkflowRunEntry, - pending_forms: Mapping[str, HitlRequired], - forms: Mapping[str, HumanInputFormRecord], - published_form_ids: set[str], - ) -> None: - """Publish materialized completions supplied by run orchestration.""" - variable_pool = workflow_entry.graph_engine.runtime_state.variable_pool - for form_id, reason in pending_forms.items(): - form = forms[form_id] - node_id = resolve_human_input_node_id(node_id=reason.node_id, form_id=form_id, variable_pool=variable_pool) - node_title = reason.node_title or form.definition.node_title or form.node_id - if form.status == HumanInputFormStatus.TIMEOUT: - self._publish_event( - QueueHumanInputFormTimeoutEvent( - form_id=form_id, - node_id=node_id, - node_type=BuiltinNodeTypes.HUMAN_INPUT, - node_title=node_title, - expiration_time=form.expiration_time, - ) - ) - published_form_ids.add(form_id) - elif form.status == HumanInputFormStatus.SUBMITTED: - action = next( - (item for item in form.definition.user_actions if item.id == form.selected_action_id), None - ) - if action is None: - raise ValueError(f"Submitted human input form has no matching action: {form_id}") - from core.workflow.node_runtime import DifyHumanInputNodeRuntime, resolve_dify_run_context - - run_context = resolve_dify_run_context(workflow_entry.graph_engine.graph.root_node.run_context) - restored_data = DifyHumanInputNodeRuntime(run_context).restore_submitted_data( - inputs=form.definition.inputs, - submitted_data=form.submitted_data or {}, - ) - submitted_data = {name: build_segment(value) for name, value in restored_data.items()} - self._publish_event( - QueueHumanInputFormFilledEvent( - form_id=form_id, - node_id=node_id, - node_type=BuiltinNodeTypes.HUMAN_INPUT, - node_title=node_title, - rendered_content=DifyHITLCallback.render_form_content_with_outputs( - form.rendered_content, - submitted_data, - [item.output_variable_name for item in form.definition.inputs], - form.definition.inputs, - ), - action_id=action.id, - action_text=action.title, - submitted_data=submitted_data, - ) - ) - published_form_ids.add(form_id) - - def handle_event( - self, - workflow_entry: WorkflowRunEntry, - event: EngineEvent, - *, - node_run_index: int = 1, - node_execution_snapshots: tuple[NodeExecutionSnapshot, ...] = (), - pause_reasons: Sequence[PauseReason] = (), - human_input_v2_forms: Mapping[str, PreparedForm] | None = None, - ) -> None: - """ - Handle event - :param workflow_entry: workflow entry - :param event: event - """ - node_metadata: dict[str, Any] = ( - {str(key): value for key, value in event.node_run_result.metadata.items()} - if isinstance(event, NodeEvent) - else {} - ) - iteration_id = node_metadata.get(WorkflowNodeExecutionMetadataKey.ITERATION_ID.value) - loop_id = node_metadata.get(WorkflowNodeExecutionMetadataKey.LOOP_ID.value) - match event: - case NodeRunHumanInputFormFilledEvent(): - form_id = event.form_id if isinstance(event, NodeRunHumanInputV2FormFilledEvent) else event.id - self._publish_event( - QueueHumanInputFormFilledEvent( - form_id=form_id, - node_id=resolve_human_input_node_id( - node_id=event.node_id, - form_id=form_id, - variable_pool=workflow_entry.graph_engine.runtime_state.variable_pool, - ), - node_type=event.node_type, - node_title=event.node_title, - rendered_content=event.rendered_content, - action_id=event.action_id, - action_text=event.action_text, - submitted_data=event.submitted_data, - ) - ) - case NodeRunHumanInputFormTimeoutEvent(): - form_id = event.form_id if isinstance(event, NodeRunHumanInputV2FormTimeoutEvent) else event.id - self._publish_event( - QueueHumanInputFormTimeoutEvent( - form_id=form_id, - node_id=resolve_human_input_node_id( - node_id=event.node_id, - form_id=form_id, - variable_pool=workflow_entry.graph_engine.runtime_state.variable_pool, - ), - node_type=event.node_type, - node_title=event.node_title, - expiration_time=event.expiration_time, - ) - ) - case GraphRunStartedEvent(): - self._publish_event( - QueueWorkflowStartedEvent(reason=event.reason, node_execution_snapshots=node_execution_snapshots) - ) - case GraphRunSucceededEvent(): - self._publish_event(QueueWorkflowSucceededEvent(outputs=event.outputs)) - case GraphRunPartialSucceededEvent(): - self._publish_event( - QueueWorkflowPartialSuccessEvent(outputs=event.outputs, exceptions_count=event.exceptions_count) - ) - case GraphRunFailedEvent(): - self._publish_event( - QueueWorkflowFailedEvent(error=event.error, exceptions_count=event.exceptions_count) - ) - case GraphRunAbortedEvent(): - self._publish_event( - QueueStopEvent( - stopped_by=QueueStopEvent.StopBy.USER_MANUAL, - reason=event.reason or "Workflow execution aborted", - ) - ) - case GraphRunPausedEvent(): - paused_nodes = list( - dict.fromkeys(reason.node_id for reason in pause_reasons if isinstance(reason, HumanInputRequired)) - ) - forms = { - reason.form_id: (human_input_v2_forms or {})[reason.form_id] - for reason in pause_reasons - if isinstance(reason, HumanInputRequired) and reason.form_version == "2" - } - self._enqueue_human_input_v1_notifications( - [ - reason - for reason in pause_reasons - if isinstance(reason, HumanInputRequired) and reason.form_version == "1" - ] - ) - self._publish_event( - QueueWorkflowPausedEvent( - reasons=list(pause_reasons), - outputs=event.outputs, - paused_nodes=paused_nodes, - human_input_v2_forms=forms, - ) - ) - case NodeRunRetryEvent(): - node_run_result = event.node_run_result - inputs = node_run_result.inputs - process_data = node_run_result.process_data - outputs = project_node_outputs_for_workflow_run( - node_type=event.node_type, - inputs=inputs, - outputs=node_run_result.outputs, - ) - execution_metadata = node_run_result.metadata - self._publish_event( - QueueNodeRetryEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_title=event.node_title, - node_type=event.node_type, - start_at=event.start_at, - in_iteration_id=iteration_id, - in_loop_id=loop_id, - inputs=inputs, - process_data=process_data, - outputs=outputs, - error=event.error, - execution_metadata=execution_metadata, - retry_index=event.retry_index, - provider_type=event.provider_type, - provider_id=event.provider_id, - ) - ) - case NodeRunStartedEvent(): - self._publish_event( - QueueNodeStartedEvent( - node_execution_id=event.id, - node_run_index=node_run_index, - node_id=event.node_id, - node_title=event.node_title, - node_type=event.node_type, - start_at=event.start_at, - in_iteration_id=iteration_id, - in_loop_id=loop_id, - agent_strategy=self._build_agent_strategy_info(event), - provider_type=event.provider_type, - provider_id=event.provider_id, - ) - ) - case NodeRunSucceededEvent(): - node_run_result = event.node_run_result - inputs = node_run_result.inputs - process_data = node_run_result.process_data - outputs = project_node_outputs_for_workflow_run( - node_type=event.node_type, - inputs=inputs, - outputs=node_run_result.outputs, - ) - execution_metadata = node_run_result.metadata - if event.node_type == BuiltinNodeTypes.ANSWER and (iteration_id or loop_id): - answer = outputs.get("answer") - if isinstance(answer, str) and answer: - self._publish_event( - QueueTextChunkEvent( - text=answer, - from_variable_selector=[event.node_id, "answer"], - in_iteration_id=iteration_id, - in_loop_id=loop_id, - ) - ) - self._publish_event( - QueueNodeSucceededEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_type=event.node_type, - start_at=event.start_at, - finished_at=event.finished_at, - inputs=inputs, - process_data=process_data, - outputs=outputs, - execution_metadata=execution_metadata, - in_iteration_id=iteration_id, - in_loop_id=loop_id, - ) - ) - case NodeRunFailedEvent(): - outputs = project_node_outputs_for_workflow_run( - node_type=event.node_type, - inputs=event.node_run_result.inputs, - outputs=event.node_run_result.outputs, - ) - self._publish_event( - QueueNodeFailedEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_type=event.node_type, - start_at=event.start_at, - finished_at=event.finished_at, - inputs=event.node_run_result.inputs, - process_data=event.node_run_result.process_data, - outputs=outputs, - error=event.node_run_result.error or "Unknown error", - execution_metadata=event.node_run_result.metadata, - in_iteration_id=iteration_id, - in_loop_id=loop_id, - ) - ) - case NodeRunExceptionEvent(): - outputs = project_node_outputs_for_workflow_run( - node_type=event.node_type, - inputs=event.node_run_result.inputs, - outputs=event.node_run_result.outputs, - ) - self._publish_event( - QueueNodeExceptionEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_type=event.node_type, - start_at=event.start_at, - finished_at=event.finished_at, - inputs=event.node_run_result.inputs, - process_data=event.node_run_result.process_data, - outputs=outputs, - error=event.node_run_result.error or "Unknown error", - execution_metadata=event.node_run_result.metadata, - in_iteration_id=iteration_id, - in_loop_id=loop_id, - ) - ) - case NodeRunStreamChunkEvent(): - self._publish_event( - QueueTextChunkEvent( - text=event.chunk, - from_variable_selector=list(event.selector), - in_iteration_id=iteration_id, - in_loop_id=loop_id, - ) - ) - case NodeRunReasoningChunkEvent(): - self._publish_event( - QueueReasoningChunkEvent( - reasoning=event.chunk, - from_node_id=event.node_id, - is_final=event.is_final, - in_iteration_id=iteration_id, - in_loop_id=loop_id, - ) - ) - case NodeRunRetrieverResourceEvent(): - self._publish_event( - QueueRetrieverResourcesEvent( - retriever_resources=[ - RetrievalSourceMetadata.model_validate(resource) for resource in event.retriever_resources - ], - in_iteration_id=iteration_id, - in_loop_id=loop_id, - ) - ) - case NodeRunAgentLogEvent(): - self._publish_event( - QueueAgentLogEvent( - 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, - node_id=event.node_id, - ) - ) - case NodeRunIterationStartedEvent(): - self._publish_event( - QueueIterationStartEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_type=event.node_type, - node_title=event.node_title, - start_at=event.start_at, - inputs=event.inputs, - metadata=_merge_container_metadata(event.metadata, node_metadata), - ) - ) - case NodeRunIterationNextEvent(): - self._publish_event( - QueueIterationNextEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_type=event.node_type, - node_title=event.node_title, - index=event.index, - output=event.pre_iteration_output, - ) - ) - case NodeRunIterationSucceededEvent() | NodeRunIterationFailedEvent(): - self._publish_event( - QueueIterationCompletedEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_type=event.node_type, - node_title=event.node_title, - start_at=event.start_at, - inputs=event.inputs, - outputs=event.outputs, - metadata=_merge_container_metadata(event.metadata, node_metadata), - steps=event.steps, - error=event.error if isinstance(event, NodeRunIterationFailedEvent) else None, - ) - ) - case NodeRunLoopStartedEvent(): - self._publish_event( - QueueLoopStartEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_type=event.node_type, - node_title=event.node_title, - start_at=event.start_at, - inputs=event.inputs, - metadata=_merge_container_metadata(event.metadata, node_metadata), - ) - ) - case NodeRunLoopNextEvent(): - self._publish_event( - QueueLoopNextEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_type=event.node_type, - node_title=event.node_title, - index=event.index, - output=event.pre_loop_output, - ) - ) - case NodeRunLoopSucceededEvent() | NodeRunLoopFailedEvent(): - self._publish_event( - QueueLoopCompletedEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_type=event.node_type, - node_title=event.node_title, - start_at=event.start_at, - inputs=event.inputs, - outputs=event.outputs, - metadata=_merge_container_metadata(event.metadata, node_metadata), - steps=event.steps, - error=event.error if isinstance(event, NodeRunLoopFailedEvent) else None, - ) - ) - - def _enqueue_human_input_v1_notifications(self, reasons: Sequence[HumanInputRequired]) -> None: - for reason in reasons: - if not reason.form_id: - continue - try: - dispatch_human_input_form_delivery_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 form delivery task for form %s", reason.form_id) - - def _publish_event(self, event: AppQueueEvent): - self._queue_manager.publish(event, PublishFrom.APPLICATION_MANAGER) +__all__ = [ + "PreparedWorkflowRun", + "WorkflowBasedAppRunner", + "WorkflowRunDriver", +] diff --git a/api/core/ops/base_trace_instance.py b/api/core/ops/base_trace_instance.py index 4e349457097201..7d6244b68e48af 100644 --- a/api/core/ops/base_trace_instance.py +++ b/api/core/ops/base_trace_instance.py @@ -22,7 +22,7 @@ DifyRunContext, WorkflowAppGenerateEntity, ) - from core.workflow.nodes.agent_v2.runtime_request_builder import WorkflowAgentRuntimeBuildContext + from services.workflow.execution.adapters.agent_v2.runtime_request_builder import WorkflowAgentRuntimeBuildContext class AgentAppTracingContext(Protocol): diff --git a/api/core/plugin/backwards_invocation/app.py b/api/core/plugin/backwards_invocation/app.py index b909d994bbd2c5..f955bc87afcd1b 100644 --- a/api/core/plugin/backwards_invocation/app.py +++ b/api/core/plugin/backwards_invocation/app.py @@ -1,339 +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.workflow_execution_identity import build_workflow_execution_identity +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, - execution_identity=build_workflow_execution_identity(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/repositories/human_input_repository.py b/api/core/repositories/human_input_repository.py index 0ee30d258ff0ed..5c0f0b2e7760e3 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, ) @@ -176,33 +179,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 - # Retain chatflow conversation correlation alongside the workflow run owner. - 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/core/tools/workflow_as_tool/tool.py b/api/core/tools/workflow_as_tool/tool.py index 9598b4ebb6494a..bf4c990d06d9da 100644 --- a/api/core/tools/workflow_as_tool/tool.py +++ b/api/core/tools/workflow_as_tool/tool.py @@ -1,55 +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 services.workflow_execution_identity import build_workflow_execution_identity +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, @@ -62,185 +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, - execution_identity=build_workflow_execution_identity(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, @@ -249,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"] diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py deleted file mode 100644 index 397fb4523492ba..00000000000000 --- a/api/core/workflow/node_factory.py +++ /dev/null @@ -1,851 +0,0 @@ -import importlib -import pkgutil -from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence -from copy import copy -from dataclasses import dataclass -from functools import lru_cache -from typing import TYPE_CHECKING, Any, 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, parse_human_input_delivery_methods -from core.workflow.llm_environment_variable import ( - parse_llm_model_selector, - resolve_llm_model_config, - should_resolve_llm_model_selector, -) -from core.workflow.llm_node import DifyLLMNode -from core.workflow.node_runtime import ( - DifyFileReferenceFactory, - DifyHumanInputNodeRuntime, - DifyPreparedLLM, - DifyPreparedPollingLLM, - DifyPromptMessageSerializer, - DifyRetrieverAttachmentLoader, - DifyToolFileManager, - DifyToolNodeRuntime, - build_dify_llm_file_saver, - build_knowledge_fs_node_dependencies, -) -from core.workflow.nodes.agent.message_transformer import AgentMessageTransformer -from core.workflow.nodes.agent.plugin_strategy_adapter import ( - PluginAgentStrategyPresentationProvider, - PluginAgentStrategyResolver, -) -from core.workflow.nodes.agent.runtime_support import AgentRuntimeSupport -from core.workflow.nodes.agent_v2.agent_node 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.nodes.human_input_v2.node import HumanInputNode as HumanInputV2Node -from core.workflow.system_variables import SystemVariableKey, get_system_text, system_variable_selector -from core.workflow.template_rendering import CodeExecutorJinja2TemplateRenderer -from core.workflow.workflow_tool_node import DifyWorkflowToolNode -from extensions.application_services.data_sources import build_data_source_credentials -from graphon.entities.base_node_data import BaseNodeData -from graphon.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter -from graphon.enums import BuiltinNodeTypes, NodeExecutionType, NodeType -from graphon.file.file_manager import file_manager -from graphon.graph.graph import NodeFactory -from graphon.model_runtime.memory import PromptMessageMemory -from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel -from graphon.nodes.base.node import Node -from graphon.nodes.code.code_node import CodeExecutorProtocol -from graphon.nodes.code.entities import CodeLanguage -from graphon.nodes.code.limits import CodeNodeLimits -from graphon.nodes.document_extractor import UnstructuredApiConfig -from graphon.nodes.http_request import build_http_request_config -from graphon.nodes.llm.entities import LLMNodeData -from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData -from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData -from graphon.nodes.tool.entities import ToolProviderType -from graphon.variables.segments import ArrayObjectSegment, ObjectSegment -from models.human_input_entities import HumanInputNodeData as DifyHumanInputNodeData -from models.model import Conversation -from services.human_input_v2.composition import WorkflowHumanInputRuntime -from services.knowledge_fs.knowledge_retrieval_v2.automatic_metadata_filter import ( - KnowledgeFSAutomaticMetadataFilterExtractor, -) -from services.knowledge_fs.knowledge_retrieval_v2.entities import KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE - -if TYPE_CHECKING: - from core.app.apps.workflow_app_runner import WorkflowRunDriver - from core.tools.workflow_as_tool.tool import WorkflowTool - from graphon.runtime import InitParams, RuntimeState - -LATEST_VERSION = "latest" -_START_NODE_TYPES: frozenset[NodeType] = frozenset( - (BuiltinNodeTypes.START, BuiltinNodeTypes.DATASOURCE, *TRIGGER_NODE_TYPES) -) - - -@dataclass(frozen=True, slots=True) -class DifyGraphInitContext: - """Explicit graph-init values owned by the workflow layer. - - Dify is gradually removing direct `InitParams` construction from its - production call sites. Keep the translation here until `graphon` exposes an - equivalent explicit API. - """ - - workflow_id: str - graph_config: Mapping[str, Any] - run_context: Mapping[str, Any] - call_depth: int - execution_driver: "WorkflowRunDriver | None" = None - - def to_graph_init_params(self) -> "InitParams": - from graphon.runtime import InitParams - - return InitParams( - workflow_id=self.workflow_id, - graph_config=self.graph_config, - run_context=self.run_context, - call_depth=self.call_depth, - ) - - -def _import_node_package(package_name: str, *, excluded_modules: frozenset[str] = frozenset()) -> None: - package = importlib.import_module(package_name) - for _, module_name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + "."): - if module_name in excluded_modules: - continue - importlib.import_module(module_name) - - -@lru_cache(maxsize=1) -def register_nodes() -> None: - """Import production node modules so they self-register with ``Node``.""" - _import_node_package("graphon.nodes") - _import_node_package("core.workflow.nodes") - _import_node_package("services.knowledge_fs.knowledge_retrieval_v2") - - -def get_node_type_classes_mapping() -> Mapping[NodeType, Mapping[str, type[Node]]]: - """Return a read-only snapshot of the current production node registry. - - The workflow layer owns node bootstrap because it must compose built-in - `graphon.nodes.*` implementations with workflow-local nodes under - `core.workflow.nodes.*`. Keeping this import side effect here avoids - reintroducing registry bootstrapping into lower-level graph primitives. - """ - register_nodes() - return Node.get_node_type_classes_mapping() - - -def resolve_workflow_node_class( - *, - node_type: NodeType, - node_version: str, - node_data: Mapping[str, Any] | BaseNodeData | None = None, -) -> type[Node]: - """Resolve the production node class for the requested type/version.""" - node_mapping = get_node_type_classes_mapping().get(node_type) - if not node_mapping: - raise ValueError(f"No class mapping found for node type: {node_type}") - - # Historical Agent nodes used version=2 for their tool-parameter format. - # Only the explicit kind marker identifies the newer Dify Agent node. - if ( - node_data is not None - and node_type == BuiltinNodeTypes.AGENT - and node_version == "2" - and not is_dify_agent_node_data(node_data) - ): - node_version = "1" - - latest_node_class = node_mapping.get(LATEST_VERSION) - matched_node_class = node_mapping.get(node_version) - node_class = matched_node_class or latest_node_class - if not node_class: - raise ValueError(f"No latest version class found for node type: {node_type}") - return node_class - - -def is_start_node_type(node_type: NodeType) -> bool: - """Return True when the node type can serve as a workflow entry point.""" - return node_type in _START_NODE_TYPES - - -def get_default_root_node_id(graph_config: Mapping[str, Any]) -> str: - """Resolve the default entry node for a persisted top-level workflow graph. - - This workflow-layer helper depends on start-node semantics defined by - `is_start_node_type`, so it intentionally lives next to the node registry - instead of in the raw `graphon.entities.graph_config` schema module. - """ - nodes = graph_config.get("nodes") - if not isinstance(nodes, list): - raise ValueError("nodes in workflow graph must be a list") - - for node in nodes: - if not isinstance(node, Mapping): - continue - - if node.get("type") == "custom-note": - continue - - node_id = node.get("id") - data = node.get("data") - if not isinstance(node_id, str) or not isinstance(data, Mapping): - continue - - node_type = data.get("type") - if isinstance(node_type, str) and is_start_node_type(node_type): - return node_id - - raise ValueError("Unable to determine default root node ID from workflow graph") - - -class _LazyNodeTypeClassesMapping(MutableMapping[NodeType, Mapping[str, type[Node]]]): - """Mutable dict-like view over the current node registry.""" - - def __init__(self) -> None: - self._cached_snapshot: dict[NodeType, Mapping[str, type[Node]]] = {} - self._cached_version = -1 - self._deleted: set[NodeType] = set() - self._overrides: dict[NodeType, Mapping[str, type[Node]]] = {} - - def _snapshot(self) -> dict[NodeType, Mapping[str, type[Node]]]: - current_version = Node.get_registry_version() - if self._cached_version != current_version: - self._cached_snapshot = dict(get_node_type_classes_mapping()) - self._cached_version = current_version - if not self._deleted and not self._overrides: - return self._cached_snapshot - - snapshot = {key: value for key, value in self._cached_snapshot.items() if key not in self._deleted} - snapshot.update(self._overrides) - return snapshot - - @override - def __getitem__(self, key: NodeType) -> Mapping[str, type[Node]]: - return self._snapshot()[key] - - @override - def __setitem__(self, key: NodeType, value: Mapping[str, type[Node]]) -> None: - self._deleted.discard(key) - self._overrides[key] = value - - @override - def __delitem__(self, key: NodeType) -> None: - if key in self._overrides: - del self._overrides[key] - return - if key in self._cached_snapshot: - self._deleted.add(key) - return - raise KeyError(key) - - @override - def __iter__(self) -> Iterator[NodeType]: - return iter(self._snapshot()) - - @override - def __len__(self) -> int: - return len(self._snapshot()) - - -# Keep the canonical node-class mapping in the workflow layer that also bootstraps -# legacy `core.workflow.nodes.*` registrations. -NODE_TYPE_CLASSES_MAPPING: MutableMapping[NodeType, Mapping[str, type[Node]]] = _LazyNodeTypeClassesMapping() - - -type LLMCompatibleNodeData = LLMNodeData | QuestionClassifierNodeData | ParameterExtractorNodeData - - -def fetch_memory( - *, - conversation_id: str | None, - app_id: str, - node_data_memory: MemoryConfig | None, - model_instance: ModelInstance, -) -> TokenBufferMemory | None: - """Build prompt memory for node construction without requiring Flask-local state.""" - if not node_data_memory or not conversation_id: - return None - - # Node construction can happen in graph initialization paths where Flask's - # app context is not active. Use the app-configured session factory instead - # of resolving db.engine through Flask-SQLAlchemy's current_app proxy. - with session_factory.create_session() as session: - stmt = select(Conversation).where(Conversation.app_id == app_id, Conversation.id == conversation_id) - conversation = session.scalar(stmt) - if not conversation: - return None - - return TokenBufferMemory(conversation=conversation, model_instance=model_instance) - - -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, - runtime_state: "RuntimeState", - human_input_run_context: Mapping[str, Any] | DifyRunContext | None = None, - use_workflow_tool_containers: bool = True, - workflow_tools: dict[str, "WorkflowTool"] | None = None, - ) -> "DifyNodeFactory": - """Bridge Dify's explicit init context into the current `graphon` API.""" - return cls( - init_params=graph_init_context.to_graph_init_params(), - runtime_state=runtime_state, - human_input_run_context=human_input_run_context, - use_workflow_tool_containers=use_workflow_tool_containers, - execution_driver=graph_init_context.execution_driver, - workflow_tools=workflow_tools, - ) - - def __init__( - self, - init_params: "InitParams", - runtime_state: "RuntimeState", - human_input_run_context: Mapping[str, Any] | DifyRunContext | None = None, - use_workflow_tool_containers: bool = True, - workflow_tools: dict[str, "WorkflowTool"] | None = None, - execution_driver: "WorkflowRunDriver | None" = None, - ) -> None: - self.init_params = init_params - self.runtime_state = runtime_state - self._dify_context = self._resolve_dify_context(init_params.run_context) - self._use_workflow_tool_containers = use_workflow_tool_containers - self._execution_driver = execution_driver - self._human_input_run_context = ( - self._dify_context - if human_input_run_context is None - else ( - human_input_run_context - if isinstance(human_input_run_context, DifyRunContext) - else self._resolve_dify_context(human_input_run_context) - ) - ) - self._code_executor: CodeExecutorProtocol = DefaultWorkflowCodeExecutor() - self._code_limits = CodeNodeLimits( - max_string_length=dify_config.CODE_MAX_STRING_LENGTH, - max_number=dify_config.CODE_MAX_NUMBER, - min_number=dify_config.CODE_MIN_NUMBER, - max_precision=dify_config.CODE_MAX_PRECISION, - max_depth=dify_config.CODE_MAX_DEPTH, - max_number_array_length=dify_config.CODE_MAX_NUMBER_ARRAY_LENGTH, - max_string_array_length=dify_config.CODE_MAX_STRING_ARRAY_LENGTH, - max_object_array_length=dify_config.CODE_MAX_OBJECT_ARRAY_LENGTH, - ) - self._jinja2_template_renderer = CodeExecutorJinja2TemplateRenderer() - self._template_transform_max_output_length = dify_config.TEMPLATE_TRANSFORM_MAX_LENGTH - self._http_request_http_client = graphon_ssrf_proxy - self._remote_file_http_client = remote_fetcher.graphon_remote_file_fetcher - self._bound_tool_file_manager_factory = lambda: DifyToolFileManager( - self._dify_context, - conversation_id_getter=self._conversation_id, - ) - self._file_reference_factory = DifyFileReferenceFactory(self._dify_context) - self._prompt_message_serializer = DifyPromptMessageSerializer() - self._retriever_attachment_loader = DifyRetrieverAttachmentLoader( - file_reference_factory=self._file_reference_factory, - ) - self._llm_file_saver = build_dify_llm_file_saver( - run_context=self._dify_context, - http_client=self._remote_file_http_client, - conversation_id_getter=self._conversation_id, - ) - self._human_input_runtime = DifyHumanInputNodeRuntime( - self._human_input_run_context, - workflow_execution_id_getter=lambda: get_system_text( - self.runtime_state.variable_pool, - SystemVariableKey.WORKFLOW_EXECUTION_ID, - ), - conversation_id_getter=self._conversation_id, - ) - self.workflow_tools = workflow_tools if workflow_tools is not None else {} - self._tool_runtime = DifyToolNodeRuntime( - self._dify_context, execution_driver=execution_driver, workflow_tools=self.workflow_tools - ) - self._http_request_file_manager = file_manager - self._document_extractor_unstructured_api_config = UnstructuredApiConfig( - api_url=dify_config.UNSTRUCTURED_API_URL, - api_key=dify_config.UNSTRUCTURED_API_KEY or "", - ) - self._http_request_config = build_http_request_config( - max_connect_timeout=dify_config.HTTP_REQUEST_MAX_CONNECT_TIMEOUT, - max_read_timeout=dify_config.HTTP_REQUEST_MAX_READ_TIMEOUT, - max_write_timeout=dify_config.HTTP_REQUEST_MAX_WRITE_TIMEOUT, - max_binary_size=dify_config.HTTP_REQUEST_NODE_MAX_BINARY_SIZE, - max_text_size=dify_config.HTTP_REQUEST_NODE_MAX_TEXT_SIZE, - ssl_verify=dify_config.HTTP_REQUEST_NODE_SSL_VERIFY, - ssrf_default_max_retries=dify_config.SSRF_DEFAULT_MAX_RETRIES, - ) - - self._llm_credentials_provider, self._llm_model_factory = build_dify_model_access(self._dify_context) - self._agent_strategy_resolver = PluginAgentStrategyResolver() - self._agent_strategy_presentation_provider = PluginAgentStrategyPresentationProvider() - self._agent_runtime_support = AgentRuntimeSupport() - self._agent_message_transformer = AgentMessageTransformer( - lambda name, default: self._tool_runtime.resolve_provider_icons(provider_name=name, default_icon=default) - ) - - @override - def with_runtime_state(self, runtime_state: "RuntimeState") -> "DifyNodeFactory": - return DifyNodeFactory( - init_params=self.init_params, - runtime_state=runtime_state, - human_input_run_context=self._human_input_run_context, - use_workflow_tool_containers=self._use_workflow_tool_containers, - workflow_tools=self.workflow_tools, - execution_driver=self._execution_driver, - ) - - @override - def with_graph_config(self, graph_config: Mapping[str, Any]) -> "DifyNodeFactory": - factory = copy(self) - factory.init_params = self.init_params.model_copy(update={"graph_config": graph_config}) - return factory - - @property - def execution_driver(self) -> "WorkflowRunDriver | None": - return self._execution_driver - - @property - def human_input_run_context(self) -> DifyRunContext: - return self._human_input_run_context - - @staticmethod - def _resolve_dify_context(run_context: Mapping[str, Any]) -> DifyRunContext: - raw_ctx = run_context.get(DIFY_RUN_CONTEXT_KEY) - if raw_ctx is None: - raise ValueError(f"run_context missing required key: {DIFY_RUN_CONTEXT_KEY}") - if isinstance(raw_ctx, DifyRunContext): - return raw_ctx - return DifyRunContext.model_validate(raw_ctx) - - def _conversation_id(self) -> str | None: - return get_system_text(self.runtime_state.variable_pool, SystemVariableKey.CONVERSATION_ID) - - @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, typed_node_config, node_class, resolved_node_data = self._validate_node_config(node_config) - node_id = typed_node_config["id"] - node_data = typed_node_config["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: ( - { - "human_input_runtime": WorkflowHumanInputRuntime(self._dify_context), - } - if node_class is HumanInputV2Node - else { - "hitl_callback": self._build_human_input_callback( - node_data=DifyHumanInputNodeData.model_validate(adapted_node_config["data"]), - execution_id_getter=lambda: node.execution_id if node is not None else None, - ), - } - ), - BuiltinNodeTypes.LLM: lambda: self._build_llm_compatible_node_init_kwargs( - node_class=node_class, - node_data=resolved_node_data, - wrap_model_instance=True, - include_http_client=True, - include_llm_file_saver=True, - include_prompt_message_serializer=True, - include_retriever_attachment_loader=True, - include_jinja2_template_renderer=True, - ), - BuiltinNodeTypes.DOCUMENT_EXTRACTOR: lambda: { - "unstructured_api_config": self._document_extractor_unstructured_api_config, - "http_client": self._remote_file_http_client, - }, - BuiltinNodeTypes.QUESTION_CLASSIFIER: lambda: self._build_llm_compatible_node_init_kwargs( - node_class=node_class, - node_data=resolved_node_data, - wrap_model_instance=True, - include_http_client=True, - include_llm_file_saver=True, - include_prompt_message_serializer=True, - include_retriever_attachment_loader=False, - include_jinja2_template_renderer=False, - ), - BuiltinNodeTypes.PARAMETER_EXTRACTOR: lambda: self._build_llm_compatible_node_init_kwargs( - node_class=node_class, - node_data=resolved_node_data, - wrap_model_instance=True, - include_http_client=False, - include_llm_file_saver=False, - include_prompt_message_serializer=True, - include_retriever_attachment_loader=False, - include_jinja2_template_renderer=False, - ), - BuiltinNodeTypes.TOOL: lambda: { - "tool_file_manager": self._bound_tool_file_manager_factory(), - "runtime": self._tool_runtime, - }, - BuiltinNodeTypes.AGENT: lambda: self._build_agent_node_init_kwargs(node_class=node_class), - KNOWLEDGE_RETRIEVAL_V2_NODE_TYPE: lambda: { - **build_knowledge_fs_node_dependencies(), - "metadata_filter_extractor": KnowledgeFSAutomaticMetadataFilterExtractor( - credentials_provider=self._llm_credentials_provider, - model_factory=self._llm_model_factory, - ), - }, - } - node_init_kwargs = node_init_kwargs_factories.get(node_type, lambda: {})() - constructor_node_data = resolved_node_data.model_dump(mode="python", by_alias=True) - node = node_class( - node_id=node_id, - data=constructor_node_data, - init_params=self.init_params, - runtime_state=self.runtime_state, - **node_init_kwargs, - ) - return node - - @override - def validate_node(self, node_config: NodeConfigDict) -> NodeExecutionType: - adapted_node_config, typed_node_config, node_class, _ = self._validate_node_config(node_config) - if typed_node_config["data"].type == BuiltinNodeTypes.HUMAN_INPUT: - node_data = DifyHumanInputNodeData.model_validate(adapted_node_config["data"]) - parse_human_input_delivery_methods(node_data) - return node_class.execution_type - - def _validate_node_config( - self, - node_config: Mapping[str, Any], - ) -> tuple[dict[str, Any], NodeConfigDict, type[Node], BaseNodeData]: - adapted_node_config = adapt_node_config_for_graph(node_config) - typed_node_config = NodeConfigDictAdapter.validate_python(adapted_node_config) - node_data = typed_node_config["data"] - node_class = self._resolve_node_class_for_factory( - node_type=node_data.type, - node_version=str(node_data.version), - node_data=node_data, - ) - resolved_node_data = self._validate_resolved_node_data(node_class, node_data) - return adapted_node_config, typed_node_config, node_class, resolved_node_data - - @staticmethod - def _validate_resolved_node_data(node_class: type[Node], node_data: BaseNodeData) -> BaseNodeData: - """ - Re-validate the permissive graph payload with the concrete NodeData model declared by the resolved node class. - """ - validate_node_data = getattr(node_class, "validate_node_data", None) - if callable(validate_node_data): - return cast("BaseNodeData", validate_node_data(node_data)) - return node_data - - def _resolve_node_class_for_factory( - self, - *, - node_type: NodeType, - node_version: str, - node_data: BaseNodeData, - ) -> type[Node]: - if ( - self._use_workflow_tool_containers - and node_type == BuiltinNodeTypes.TOOL - and node_data.model_dump().get("provider_type") == ToolProviderType.WORKFLOW - ): - return DifyWorkflowToolNode - return self._resolve_node_class( - node_type=node_type, - node_version=node_version, - node_data=node_data, - ) - - @staticmethod - def _resolve_node_class( - *, - node_type: NodeType, - node_version: str, - node_data: Mapping[str, Any] | BaseNodeData | None = None, - ) -> type[Node]: - if node_type == BuiltinNodeTypes.LLM: - return DifyLLMNode - return resolve_workflow_node_class( - node_type=node_type, - node_version=node_version, - node_data=node_data, - ) - - def _resolve_llm_model_reference(self, node_data: LLMNodeData) -> LLMNodeData: - """Resolve an optional shared model selector from the workflow variable pool.""" - - model_selector = (node_data.model_extra or {}).get("model_selector") - if not should_resolve_llm_model_selector(model_selector): - return node_data - - selector = parse_llm_model_selector(model_selector) - variable = self.runtime_state.variable_pool.get(selector) - if not isinstance(variable, ObjectSegment): - raise ValueError(f"LLM environment variable '{selector[1]}' was not found or is not an LLM variable") - - resolved_model = resolve_llm_model_config( - node_model=node_data.model, - variable_name=selector[1], - variable_value=variable.value, - ) - return node_data.model_copy(update={"model": resolved_model}) - - def _build_agent_node_init_kwargs(self, *, node_class: type[Node]) -> dict[str, object]: - if issubclass(node_class, DifyAgentNode): - from clients.agent_backend import AgentBackendRunEventAdapter, AgentBackendRunRequestBuilder - from clients.agent_backend.factory import create_agent_backend_run_client - from core.workflow.nodes.agent_v2.file_tenant_validator import AgentOutputFileTenantValidator - from core.workflow.nodes.agent_v2.output_failure_orchestrator import OutputFailureOrchestrator - from core.workflow.nodes.agent_v2.output_file_rebacker import reback_tool_file_output - from core.workflow.nodes.agent_v2.output_type_checker import PerOutputTypeChecker - from core.workflow.nodes.agent_v2.session_store import WorkflowAgentWorkspaceStore - - 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.runtime_state.variable_pool.get(context_variable_selector) - if not isinstance(context_value, ArrayObjectSegment): - return False - - for item in context_value.value: - if not isinstance(item, Mapping): - continue - metadata = item.get("metadata") - if not isinstance(metadata, Mapping): - continue - if metadata.get("_source") == "knowledge" and str(metadata.get("segment_id")) == str(segment_id): - return True - return False - - return checker - - def _build_model_instance_for_llm_node(self, node_data: LLMCompatibleNodeData) -> ModelInstance: - node_data_model = node_data.model - model_instance, _ = fetch_model_config( - node_data_model=node_data_model, - credentials_provider=self._llm_credentials_provider, - model_factory=self._llm_model_factory, - ) - model_instance.model_type_instance = cast(LargeLanguageModel, model_instance.model_type_instance) - return model_instance - - def _build_memory_for_llm_node( - self, - *, - node_data: LLMCompatibleNodeData, - model_instance: ModelInstance, - ) -> PromptMessageMemory | None: - if node_data.memory is None: - return None - - conversation_id = get_system_text(self.runtime_state.variable_pool, SystemVariableKey.CONVERSATION_ID) - return fetch_memory( - conversation_id=conversation_id, - app_id=self._dify_context.app_id, - node_data_memory=node_data.memory, - model_instance=model_instance, - ) diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py deleted file mode 100644 index 925078c3f0fa36..00000000000000 --- a/api/core/workflow/node_runtime.py +++ /dev/null @@ -1,1070 +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 flask import current_app -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, ModelFeature -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 graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter -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 -from .workflow_tool_container_types import WorkflowToolContainerPayload - - -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.app.apps.workflow_app_runner import WorkflowRunDriver - from core.tools.__base.tool import Tool - from core.tools.entities.tool_entities import ToolInvokeMessage as CoreToolInvokeMessage - from core.tools.workflow_as_tool.tool import WorkflowTool - 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 = self._get_provider_model_schema() - if model_schema.features is None or ModelFeature.STRUCTURED_OUTPUT in model_schema.features: - return model_schema - # Graphon checks this adapter's capabilities before invocation. Dify can - # supply structured output through a schema prompt without native support. - # Copy the schema so provider metadata still selects the correct path. - return model_schema.model_copy(update={"features": [*model_schema.features, ModelFeature.STRUCTURED_OUTPUT]}) - - def _get_provider_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]: ... - - @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]: - 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]: ... - - @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]: - return invoke_llm_with_structured_output( - provider=self.provider, - model_schema=self._get_provider_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.""" - - @override - def get_model_schema(self) -> AIModelEntity: - # Polling passes the schema directly to the plugin and has no prompt fallback. - return self._get_provider_model_schema() - - 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, - execution_driver: WorkflowRunDriver | None = None, - workflow_tools: Mapping[str, WorkflowTool] | 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 - self._execution_driver = execution_driver - self._workflow_tools = workflow_tools if workflow_tools is not None else {} - - @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, - execution_driver=self._execution_driver, - workflow_tool_definition=( - self._workflow_tools.get(node_data.provider_id) - if self._is_workflow_tool_provider(node_data) - else None - ), - ) - tool_runtime.runtime.dify_run_context = self._run_context - 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]: - 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 build_workflow_tool_container_payload( - self, - *, - tool_runtime: ToolRuntimeHandle, - tool_parameters: Mapping[str, Any], - inputs_for_log: Mapping[str, Any], - workflow_call_depth: int, - ) -> WorkflowToolContainerPayload: - """Prepare a serializable request for Engine-owned Workflow Tool execution.""" - from core.tools.workflow_as_tool.tool import WorkflowTool - - tool = self._binding_from_handle(tool_runtime).tool - if not isinstance(tool, WorkflowTool): - raise ToolRuntimeResolutionError("resolved tool is not a Workflow Tool") - - inputs, system_files = tool.prepare_container_inputs(tool_parameters) - json_inputs = WorkflowRuntimeTypeConverter().to_json_encodable( - { - "inputs": inputs, - "system_files": system_files, - "inputs_for_log": dict(inputs_for_log), - } - ) - return WorkflowToolContainerPayload( - source_app_id=tool.workflow_app_id, - source_workflow_id=tool.workflow_id, - source_workflow_version=tool.version, - inputs=json_inputs["inputs"], - system_files=json_inputs["system_files"], - inputs_for_log=json_inputs["inputs_for_log"], - call_depth=workflow_call_depth + 1, - ) - - 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], - *, - provider_name: str, - ) -> Generator[ToolRuntimeMessage]: - 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, - *, - inputs: Sequence[FormInputConfig], - submitted_data: Mapping[str, Any], - ) -> Mapping[str, Any]: - restored_data: dict[str, Any] = dict(submitted_data) - for input_config in 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, - ) - - -def build_knowledge_fs_node_dependencies() -> dict[str, object]: - """Resolve the retrieval capabilities registered by the application composition root.""" - factory = cast(Callable[[], dict[str, object]], current_app.extensions["knowledge_fs_node_dependencies"]) - return factory() 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 d5036f09f4c12d..00000000000000 --- a/api/core/workflow/nodes/agent/agent_node.py +++ /dev/null @@ -1,221 +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.engine_events import NodeEvent -from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus -from graphon.node_events import NodeEventPayload, 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.runtime import InitParams, RuntimeState - - -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, - *, - init_params: InitParams, - runtime_state: RuntimeState, - strategy_resolver: AgentStrategyResolver, - presentation_provider: AgentStrategyPresentationProvider, - runtime_support: AgentRuntimeSupport, - message_transformer: AgentMessageTransformer, - ) -> None: - super().__init__( - node_id=node_id, - data=data, - init_params=init_params, - runtime_state=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: NodeEventPayload - ) -> NodeEvent: - 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[NodeEventPayload]: - 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.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.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.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 1385fc83ea09ab..00000000000000 --- a/api/core/workflow/nodes/agent_v2/agent_node.py +++ /dev/null @@ -1,784 +0,0 @@ -from __future__ import annotations - -import logging -from collections.abc import Generator, Mapping, Sequence -from typing import TYPE_CHECKING, Any, override - -from dify_agent.protocol import CancelRunRequest -from dify_agent.protocol.snapshot import SessionSnapshot - -from clients.agent_backend import ( - AgentBackendAgentMessageDeltaInternalEvent, - 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.workflow.node_execution_process_data import WORKFLOW_TOOL_INVOCATION_ID_KEY -from core.workflow.system_variables import SystemVariableKey, get_system_text -from graphon.engine_events import NodeRunPauseRequestedEvent -from graphon.enums import ( - BuiltinNodeTypes, - ErrorStrategy, - NodeExecutionType, - WorkflowNodeExecutionMetadataKey, - WorkflowNodeExecutionStatus, -) -from graphon.node_events import ( - NodeEventPayload, - NodeRunResult, - RunRetrieverResourceEvent, - StreamChunkEvent, - StreamCompletedEvent, -) -from graphon.nodes.base.node import Node -from graphon.nodes.base.variable_template_parser import VariableTemplateParser -from models.agent_config_entities import WorkflowNodeJobConfig, WorkflowOutputRoutes -from services.agent.knowledge_citations import knowledge_sources_from_stream_event -from services.agent.prompt_mentions import extract_workflow_node_output_selectors -from services.agent.workspace_service import AgentWorkspaceNotFoundError - -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.runtime import InitParams, RuntimeState - -logger = logging.getLogger(__name__) - - -# Stage 4 §5+§7: the terminal events that `_consume_event_stream` may return. -# Stream + started events are filtered out (plain-run text deltas are yielded -# as stream chunks instead); transport errors -# are surfaced as a separate StreamCompletedEvent in the second tuple slot. -type _TerminalAgentBackendEvent = ( - AgentBackendRunSucceededInternalEvent | AgentBackendRunFailedInternalEvent | AgentBackendRunCancelledInternalEvent -) - - -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, - *, - init_params: InitParams, - runtime_state: RuntimeState, - 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, - init_params=init_params, - runtime_state=runtime_state, - ) - 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} - - @override - def _run(self) -> Generator[NodeEventPayload | NodeRunPauseRequestedEvent]: - 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[NodeEventPayload | NodeRunPauseRequestedEvent]: - dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY)) - workflow_id = self.init_params.workflow_id - workflow_run_id = get_system_text( - self.runtime_state.variable_pool, - SystemVariableKey.WORKFLOW_EXECUTION_ID, - ) - # Chatflow sessions also carry their owning conversation. - conversation_id = get_system_text( - self.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, - workflow_tool_invocation_id=dify_ctx.workflow_tool_invocation_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 if dify_ctx.workflow_tool_invocation_id is None else None, - ) - 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, - } - ) - if dify_ctx.workflow_tool_invocation_id is not None: - process_data[WORKFLOW_TOOL_INVOCATION_ID_KEY] = dify_ctx.workflow_tool_invocation_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, - workflow_tool_invocation_id=dify_ctx.workflow_tool_invocation_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} - - stored_session = self._session_store.load_or_create_node_execution_session( - session_scope, - home_snapshot_id=bundle.snapshot.home_snapshot_id, - ) - # ──── 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.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, - ) - ) - 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 = yield from self._consume_event_stream( - create_response.run_id, - inputs=inputs, - process_data=process_data, - metadata=metadata, - stream_text=not custom_outputs - and not node_job.output_routes.enabled - and self.error_strategy != ErrorStrategy.DEFAULT_VALUE, - ) - if metadata.get("knowledge_fs_sources"): - yield RunRetrieverResourceEvent(retriever_resources=metadata["knowledge_fs_sources"], context="") - # 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 - - # 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], - stream_text: bool, - ) -> Generator[ - StreamChunkEvent, - None, - tuple[_TerminalAgentBackendEvent | None, StreamCompletedEvent | None], - ]: - """Consume the SSE stream for one Agent backend run. - - When ``stream_text`` is set (plain runs whose final string becomes the - ``text`` output), agent-message deltas are yielded as ``text`` stream - chunks so Answer nodes can stream them. The stream is closed only after - at least one chunk was sent; otherwise the response coordinator falls - back to the final ``text`` variable. Structured runs keep deltas as - metadata because their ``text`` comes from the structured output. - Plain runs with the default-value error strategy stay buffered too: - once a selector has stream chunks, the response filter no longer reads - the variable pool, so a configured fallback ``text`` would be dropped - after a partial stream. - - 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. - """ - result = yield from self._read_event_stream( - run_id, - inputs=inputs, - process_data=process_data, - metadata=metadata, - stream_text=stream_text, - ) - if stream_text and (metadata.get("agent_backend") or {}).get("agent_message_delta_count"): - yield StreamChunkEvent(selector=[self._node_id, "text"], chunk="", is_final=True) - return result - - def _read_event_stream( - self, - run_id: str, - *, - inputs: dict[str, Any], - process_data: dict[str, Any], - metadata: dict[str, Any], - stream_text: bool, - ) -> Generator[ - StreamChunkEvent, - None, - tuple[_TerminalAgentBackendEvent | None, StreamCompletedEvent | None], - ]: - 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) - if stream_text: - yield StreamChunkEvent( - selector=[self._node_id, "text"], - chunk=internal_event.delta, - is_final=False, - ) - continue - metadata["agent_backend"] = { - **dict(metadata.get("agent_backend") or {}), - "stream_event_count": stream_event_count, - } - # Narrow to known terminal event types before returning to the caller. - if isinstance( - internal_event, - AgentBackendRunSucceededInternalEvent - | AgentBackendRunFailedInternalEvent - | AgentBackendRunCancelledInternalEvent, - ): - 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 Engine's cooperative abort state.""" - return self.runtime_state.graph_execution.aborted - - 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 _save_session_snapshot( - self, - *, - session_scope: WorkflowAgentSessionScope, - binding_id: str, - snapshot: SessionSnapshot | None, - metadata: dict[str, Any], - ) -> None: - try: - self._session_store.save_active_snapshot( - scope=session_scope, - binding_id=binding_id, - snapshot=snapshot, - ) - 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, - ) - ) - - @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): - sources = knowledge_sources_from_stream_event(event.data) - if sources: - current = metadata.setdefault("knowledge_fs_sources", []) - seen = {source["knowledge_fs_citation"]["id"] for source in current} - for source in sources: - if ( - source.knowledge_fs_citation - and source.knowledge_fs_citation.id not in seen - and len(current) < 200 - ): - current.append(source.model_dump(mode="json")) - seen.add(source.knowledge_fs_citation.id) - 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 5ed847b1375440..00000000000000 --- a/api/core/workflow/nodes/agent_v2/dify_tools_builder.py +++ /dev/null @@ -1,626 +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.plugin_tool.tool import PluginTool -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 AgentKnowledgeSpaceConfig, 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, - knowledge_fs_spaces: tuple[AgentKnowledgeSpaceConfig, ...] = (), - ) -> 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, - knowledge_fs_spaces: tuple[AgentKnowledgeSpaceConfig, ...] = (), - ) -> 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) - choices = self._knowledge_fs_research_choices(normalized_tool_config, knowledge_fs_spaces) - for index, knowledge_space in enumerate(choices): - exposed_name = self._exposed_tool_name(normalized_tool_config) - if knowledge_space is not None and len(choices) > 1: - exposed_name = f"{exposed_name}_{index + 1}" - 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, - knowledge_fs_space_id=knowledge_space.control_space_id if knowledge_space else None, - ) - 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: - description = self._description(normalized_tool_config, tool_runtime) - if knowledge_space is not None and len(choices) > 1: - description = ( - f"{description or 'Create a research task.'} " - f"Agent Knowledge Base: {knowledge_space.name} (alias: {knowledge_space.id})." - ) - if knowledge_space.description: - description += f" {knowledge_space.description}" - prepared_core.append( - self._to_core_backend_tool_config( - normalized_tool_config, tool_runtime, exposed_name, description=description - ) - ) - - 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 _knowledge_fs_research_choices( - tool_config: AgentSoulDifyToolConfig, - knowledge_fs_spaces: tuple[AgentKnowledgeSpaceConfig, ...], - ) -> tuple[AgentKnowledgeSpaceConfig | None, ...]: - if ( - tool_config.provider_type is ToolProviderType.BUILT_IN - and WorkflowAgentDifyToolsBuilder._provider_id(tool_config) == "knowledge_fs" - and tool_config.tool_name == "create_research_task" - ): - spaces = tuple(space for space in knowledge_fs_spaces if not space.is_missing) - return spaces or (None,) - return (None,) - - @staticmethod - def _to_agent_tool_entity( - tool_config: AgentSoulDifyToolConfig, *, knowledge_fs_space_id: str | None - ) -> AgentToolEntity: - assert tool_config.tool_name is not None - tool_parameters: dict[str, Any] = dict(tool_config.runtime_parameters) - if ( - tool_config.provider_type is ToolProviderType.BUILT_IN - and WorkflowAgentDifyToolsBuilder._provider_id(tool_config) == "knowledge_fs" - and tool_config.tool_name == "create_research_task" - and knowledge_fs_space_id is not None - ): - # The selected Agent Knowledge Base supplies the hidden form - # parameter. The model still sees only query and mode. - tool_parameters["resource"] = { - "kind": "knowledge_fs", - "control_space_id": knowledge_fs_space_id, - } - return AgentToolEntity( - provider_type=tool_config.provider_type, - provider_id=WorkflowAgentDifyToolsBuilder._provider_id(tool_config), - tool_name=tool_config.tool_name, - tool_parameters=tool_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) - if not isinstance(tool_runtime, PluginTool) or not tool_runtime.plugin_unique_identifier: - raise WorkflowAgentDifyToolsBuildError( - "agent_tool_config_invalid", - f"Dify Tool {tool_config.tool_name!r} has no plugin installation identity.", - ) - 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, - plugin_unique_identifier=tool_runtime.plugin_unique_identifier, - 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, - description: str | None = None, - ) -> 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=description or 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 b94300f3cbb0b3..00000000000000 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ /dev/null @@ -1,1155 +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 dify_agent.agent_stub.protocol import AgentStubFileMapping -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 -from dify_agent.protocol.knowledge_fs import KnowledgeFsError -from dify_agent.protocol.snapshot import SessionSnapshot -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.ops.base_trace_instance import application_tracing_hooks -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, - AgentKnowledgeSpaceConfig, - AgentSoulConfig, - AgentSoulToolsConfig, - DeclaredArrayItem, - DeclaredOutputChildConfig, - DeclaredOutputConfig, - DeclaredOutputType, - WorkflowNodeJobConfig, - WorkflowOutputRoutes, - WorkflowPreviousNodeOutputRef, -) -from models.provider_ids import ModelProviderID -from services.agent.knowledge_runtime_config import build_knowledge_fs_layer_config -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: SessionSnapshot | 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, - knowledge_fs_spaces=tuple(agent_soul.knowledge.spaces), - ) - 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() - try: - knowledge_fs_config = build_knowledge_fs_layer_config(agent_soul, run_context=context.dify_context) - except KnowledgeFsError as exc: - raise WorkflowAgentRuntimeRequestBuildError(exc.code, exc.message) from exc - 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, - ) - - tracing_hooks = application_tracing_hooks() - 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), - trace_id=context.dify_context.trace_session_id, - tracing=( - tracing_hooks.prepare_workflow_agent( - context, get_system_text(context.variable_pool, SystemVariableKey.CONVERSATION_ID) - ) - if tracing_hooks is not None - else None - ), - ), - 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=build_knowledge_layer_config(agent_soul), - knowledge_fs=knowledge_fs_config, - config_layer_config=config_layer_config, - include_shell=dify_config.AGENT_SHELL_ENABLED, - shell_config=build_shell_layer_config(agent_soul), - session_snapshot=context.session_snapshot, - 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, - knowledge_fs_spaces: tuple[AgentKnowledgeSpaceConfig, ...], - ) -> 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, - knowledge_fs_spaces=knowledge_fs_spaces, - ) - - @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.extend(("- Uploaded workflow files:", 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 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 - return not _dangerous_without_acknowledgement(data) - - -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 277d372784151c..00000000000000 --- a/api/core/workflow/nodes/human_input/boundary.py +++ /dev/null @@ -1,148 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable - -from core.repositories.human_input_repository import HumanInputFormRecord, 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.engine.filter import EngineEventFilterContext -from graphon.engine_events import ( - EngineEvent, - NodeRunHumanInputFormFilledEvent, - NodeRunHumanInputFormTimeoutEvent, - NodeRunStartedEvent, - NodeRunSucceededEvent, -) -from graphon.entities.pause_reason import HitlRequired -from graphon.enums import BuiltinNodeTypes -from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool -from graphon.variables.segments import StringSegment - -from .constants import OUTPUT_FIELD_ACTION_ID, OUTPUT_FIELD_ACTION_VALUE, OUTPUT_FIELD_RENDERED_CONTENT, TIMEOUT_HANDLE -from .pause_reason import HumanInputRequired -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 V1 callback results; V2 nodes emit their own form lifecycle events.""" - - def __init__(self, *, form_repository: HumanInputFormSubmissionRepository) -> None: - self._form_repository = form_repository - self._node_titles: dict[str, str] = {} - self._v1_node_ids: set[str] = set() - self._app_id: str | None = None - - @property - def filter_id(self) -> str: - return "dify-human-input-form-events" - - def initialize(self, context: EngineEventFilterContext) -> None: - self._node_titles.clear() - # Graphon completion events can retain the default version. Route by - # the concrete node in the graph instead of the event's version field. - self._v1_node_ids = { - node.id - for node in (context.graph.root_node, *context.graph.nodes.values()) - if node.node_type == BuiltinNodeTypes.HUMAN_INPUT and node.version() == "1" - } - self._app_id = get_system_text(context.runtime_state.variable_pool, SystemVariableKey.APP_ID) - - def on_event(self, event: EngineEvent) -> Iterable[EngineEvent]: - if isinstance(event, NodeRunStartedEvent | NodeRunSucceededEvent) and event.node_id in self._v1_node_ids: - if isinstance(event, NodeRunStartedEvent): - self._node_titles[event.id] = event.node_title - else: - yield self._completion_event(event) - - yield event - - def flush(self) -> Iterable[EngineEvent]: - 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, - container_id=event.container_id, - node_run_result=result, - 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, - container_id=event.container_id, - node_run_result=result, - 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 resolve_human_input_v1_pause_reason( - *, - reason: HitlRequired, - form_repository: HumanInputFormSubmissionRepository, - variable_pool: ReadOnlyVariablePool | None, -) -> HumanInputRequired: - """Load the legacy form from the V1 submission repository.""" - node_version, form_id = default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id) - if node_version != "1": - raise ValueError("Expected a Human Input v1 session") - 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 build_human_input_pause_reason(reason=reason, record=record, variable_pool=variable_pool) - - -def human_input_container_selector(form_id: str) -> tuple[str, str]: - return ("__dify_workflow_tool_hitl__", form_id) - - -def resolve_human_input_node_id(*, node_id: str, form_id: str, variable_pool: ReadOnlyVariablePool | None) -> str: - """Project form ownership onto the visible graph without changing engine identity.""" - container = variable_pool.get(human_input_container_selector(form_id)) if variable_pool is not None else None - return container.value if isinstance(container, StringSegment) else node_id - - -def build_human_input_pause_reason( - *, reason: HitlRequired, record: HumanInputFormRecord, variable_pool: ReadOnlyVariablePool | None -) -> HumanInputRequired: - """Format a materialized form without reading its repository.""" - form_id = record.form_id - definition = record.definition - return HumanInputRequired( - form_id=record.form_id, - form_content=record.rendered_content, - inputs=resolve_variable_select_input_options(definition.inputs, variable_pool=variable_pool), - actions=list(definition.user_actions), - node_id=resolve_human_input_node_id(node_id=reason.node_id, form_id=form_id, variable_pool=variable_pool), - node_title=reason.node_title or definition.node_title or record.node_id, - resolved_default_values=dict(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 11d138a9d344a4..00000000000000 --- a/api/core/workflow/nodes/human_input/callback.py +++ /dev/null @@ -1,330 +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.human_input_policy import resolve_variable_select_input_options -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.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(node_version="1", form_id=created.id) - ) - - status = self._normalize_status(form.status) - if status == HumanInputFormStatus.WAITING.value and not form.submitted: - if 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): - form = self._form_repository.mark_timeout(ctx.node_id, form_id=form.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: - return PauseRequested( - session_id=self._session_binding.issue_session_id_for_form(node_version="1", 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: - form_config = self._node_data.model_copy( - update={ - "inputs": resolve_variable_select_input_options( - self._node_data.inputs, - variable_pool=ctx.variable_pool, - ) - } - ) - 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=form_config, - 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 ae84c7856d3ac9..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.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.runtime import InitParams -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 RuntimeState - -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, - *, - init_params: "InitParams", - runtime_state: "RuntimeState", - session_maker=None, - ) -> None: - super().__init__( - node_id=node_id, - data=data, - init_params=init_params, - runtime_state=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.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.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.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 b4b1c956b73b56..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.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.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 84dc399dc85836..00000000000000 --- a/api/core/workflow/workflow_entry.py +++ /dev/null @@ -1,651 +0,0 @@ -import logging -import time -from collections.abc import Callable, Generator, Mapping, Sequence -from contextlib import ExitStack -from contextvars import copy_context -from functools import partial -from typing import TYPE_CHECKING, Any, TypedDict -from uuid import uuid4 - -from configs import dify_config -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.layers.execution_context_layer import ExecutionContextLayer -from core.app.workflow.file_runtime import create_dify_workflow_file_runtime -from core.app.workflow.layers.observability import ObservabilityLayer -from core.credit_usage import CreditUsageAppType -from core.repositories.human_input_repository import HumanInputFormSubmissionRepository -from core.tools.workflow_as_tool.repository import WorkflowToolSource -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 core.workflow.workflow_tool_container_handler import ( - WorkflowToolContainerHandler, - WorkflowToolEventListenerFactory, - WorkflowToolNestedContainerHandler, -) -from extensions.otel.runtime import is_instrument_flag_enabled -from factories import file_factory -from graphon.engine import Engine -from graphon.engine.command import CommandChannel, InMemoryChannel -from graphon.engine.container_handler.builtin.iteration import IterationContainerHandler -from graphon.engine.container_handler.builtin.loop import LoopContainerHandler -from graphon.engine.filter import EngineEventFilterContext, ResponseStreamFilter, filter_engine_events -from graphon.engine.layer import ExecutionLimitsLayer, Layer -from graphon.engine_events import EngineEvent, GraphRunFailedEvent, NodeEvent, is_node_result_event -from graphon.entities.graph_config import NodeConfigDictAdapter -from graphon.errors import WorkflowNodeRunFailedError -from graphon.file import File -from graphon.file.runtime import use_workflow_file_runtime -from graphon.graph import Graph -from graphon.nodes import BuiltinNodeTypes -from graphon.nodes.base.node import Node -from graphon.nodes.container_effects import ContainerAwaitRequest -from graphon.runtime import ReadOnlyRuntimeStateWrapper, RuntimeState, VariablePool -from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_into_variable_pool -from models.workflow import Workflow -from services.workflow_node_variables import add_node_input_to_variable_pool, load_additional_node_variables - -if TYPE_CHECKING: - from core.app.apps.workflow_app_runner import WorkflowRunDriver - -logger = logging.getLogger(__name__) -_file_access_controller = DatabaseFileAccessController() - - -def iter_dify_graph_engine_events( - engine: Engine, - response_stream_filter: ResponseStreamFilter | None = None, -) -> Generator[EngineEvent]: - """ - Apply Dify's response streaming compatibility filter to Engine events. - - Graphon 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 ``WorkflowRunAgg``) so - the filter's ``paths_map`` reflects everything the engine has actually - streamed for this run. - """ - yield from filter_engine_events( - engine.run(), - context=EngineEventFilterContext.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, - graph_config: Mapping[str, Any], - graph: Graph, - call_depth: int, - graph_runtime_state: RuntimeState, - command_channel: CommandChannel | None = None, - response_stream_filter: ResponseStreamFilter | None = None, - ) -> None: - """ - Init workflow entry - :param graph_config: workflow graph config - :param graph: workflow graph - :param call_depth: call depth - :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) - """ - # 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.graph_config = graph_config - self.workflow_tool_sources: dict[str, WorkflowToolSource] = {} - self.workflow_tool_event_listener_factory: WorkflowToolEventListenerFactory | None = None - self._response_stream_filter = response_stream_filter or ResponseStreamFilter() - file_runtime = create_dify_workflow_file_runtime() - execution_context_layer = ExecutionContextLayer() - limits_layer = ExecutionLimitsLayer( - max_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME - ) - workflow_tool_event_listeners: dict[str, Callable[[NodeEvent], None]] = {} - self.graph_engine = Engine( - graph=graph, - runtime_state=graph_runtime_state, - command_channel=command_channel, - workers=dify_config.GRAPH_ENGINE_MAX_WORKERS, - file_runtime=file_runtime, - container_handler_factories=( - partial( - WorkflowToolNestedContainerHandler, - handler_factory=LoopContainerHandler, - hidden_event_listener=limits_layer.on_event, - event_listeners=workflow_tool_event_listeners, - ), - partial( - WorkflowToolNestedContainerHandler, - handler_factory=IterationContainerHandler, - hidden_event_listener=limits_layer.on_event, - event_listeners=workflow_tool_event_listeners, - ), - partial( - WorkflowToolContainerHandler, - sources=self.workflow_tool_sources, - hidden_event_listener=limits_layer.on_event, - event_listener_factory=self._create_workflow_tool_event_listener, - event_listeners=workflow_tool_event_listeners, - execution_context_factory=execution_context_layer.enter_context, - ), - ), - ) - - self.graph_engine.add_layer(execution_context_layer) - # Add execution limits layer - self.graph_engine.add_layer(limits_layer) - - # Add observability layer when OTel is enabled - if dify_config.ENABLE_OTEL or is_instrument_flag_enabled(): - self.graph_engine.add_layer(ObservabilityLayer()) - - def _create_workflow_tool_event_listener( - self, source: WorkflowToolSource, caller_workflow_id: str, caller_node_execution_id: str - ) -> Callable[[NodeEvent], None]: - if self.workflow_tool_event_listener_factory is None: - return lambda event: None - return self.workflow_tool_event_listener_factory(source, caller_workflow_id, caller_node_execution_id) - - @property - def response_stream_filter(self) -> ResponseStreamFilter: - return self._response_stream_filter - - def run(self) -> Generator[EngineEvent]: - try: - yield from iter_dify_graph_engine_events(self.graph_engine, self._response_stream_filter) - 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, - execution_driver: "WorkflowRunDriver | None", - ) -> tuple[Node, Generator[NodeEvent | ContainerAwaitRequest]]: - """ - 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, - execution_driver=execution_driver, - ) - graph_runtime_state = RuntimeState( - variable_pool=variable_pool, - start_at=time.perf_counter(), - workflow_id=workflow.id, - ) - - 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, - ) - variable_mapping = load_additional_node_variables( - node_config=node_config, - graph_config=workflow.graph_dict, - variable_mapping=variable_mapping, - variable_loader=variable_loader, - variable_pool=variable_pool, - 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, - runtime_state=graph_runtime_state, - use_workflow_tool_containers=False, - ) - node = node_factory.create_node(node_config) - - try: - generator = cls._run_node_with_layers(node) - 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)) from 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[NodeEvent | ContainerAwaitRequest]]: - """ - 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 = RuntimeState( - variable_pool=variable_pool, - start_at=time.perf_counter(), - workflow_id="", - ) - - # 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, - 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) - - 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)) from 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. - # Apply whole objects before nested overrides, independent of editor field order. - for node_variable, variable_selector in sorted(variable_mapping.items(), key=lambda item: len(item[1])): - # 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] - - # get input value - input_value = ( - user_inputs[node_variable] if node_variable in user_inputs else 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: - add_node_input_to_variable_pool(variable_pool, variable_selector, input_value) - - @staticmethod - def _run_node_with_layers(node: Node) -> Generator[NodeEvent | ContainerAwaitRequest]: - """ - Run a standalone node with the execution-context and observability layers used by workflow runs. - """ - file_runtime = create_dify_workflow_file_runtime() - command_channel = InMemoryChannel() - runtime_state = ReadOnlyRuntimeStateWrapper(node.runtime_state) - with use_workflow_file_runtime(file_runtime): - layers: Sequence[Layer] = (ExecutionContextLayer(), ObservabilityLayer()) - for layer in layers: - layer.initialize(runtime_state, command_channel) - layer.on_graph_start() - # Keep Flask and tracing scopes inside the snippet across suspension, - # without exposing them to callers interleaving multiple streams. - snippet_context = copy_context() - - node.bind_execution_id(str(uuid4())) - - def _gen(): - error: Exception | None = None - result_event: NodeEvent | None = None - layers_finished = False - node_contexts = ExitStack() - - 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) - node_contexts.close() - for layer in layers: - layer.on_graph_end(error) - - try: - for layer in layers: - node_contexts.enter_context(layer.node_run_context(node)) - for layer in layers: - layer.on_node_run_start(node) - for event in node.run(): - if isinstance(event, NodeEvent) and is_node_result_event(event): - result_event = event - finish_layers() - yield event - except Exception as exc: - error = exc - raise - finally: - finish_layers() - - def scoped_events(): - events = _gen() - error: BaseException | None = None - try: - while True: - try: - event = ( - snippet_context.run(next, events) - if error is None - else snippet_context.run(events.throw, error) - ) - except StopIteration: - return - try: - yield event - except BaseException as exc: - error = exc - else: - error = None - finally: - snippet_context.run(events.close) - - return scoped_events() diff --git a/api/core/workflow/workflow_tool_container_handler.py b/api/core/workflow/workflow_tool_container_handler.py deleted file mode 100644 index 571f28eb1a266a..00000000000000 --- a/api/core/workflow/workflow_tool_container_handler.py +++ /dev/null @@ -1,518 +0,0 @@ -from __future__ import annotations - -import json -import logging -from collections.abc import Callable, Mapping, Sequence -from contextlib import AbstractContextManager, nullcontext -from typing import Any, final - -from configs import dify_config -from core.app.app_config.features.file_upload.manager import FileUploadConfigManager -from core.app.apps.base_app_generator import BaseAppGenerator -from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom -from core.app.file_access import DatabaseFileAccessController -from core.tools.workflow_as_tool.repository import WorkflowToolSource -from core.workflow.node_execution_process_data import ( - WORKFLOW_TOOL_INVOCATION_ID_KEY, -) -from core.workflow.node_factory import DifyGraphInitContext, DifyNodeFactory, get_default_root_node_id -from core.workflow.node_runtime import resolve_dify_run_context -from core.workflow.nodes.human_input.boundary import human_input_container_selector -from core.workflow.nodes.human_input.session_binding import default_session_binding -from core.workflow.snippet_start import get_compatible_start_aliases -from core.workflow.system_variables import ( - SystemVariableKey, - get_all_system_variables, - system_variable_selector, -) -from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool -from factories import file_factory -from graphon.engine.container_handler.protocol import ContainerHandler -from graphon.engine.frame import ExecutionFrame, FrameRegistry -from graphon.engine.ready_queue import ResumeTask -from graphon.engine_events.base import NodeEvent -from graphon.engine_events.node import NodeRunFailedEvent, NodeRunPauseRequestedEvent -from graphon.entities.pause_reason import HitlRequired -from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus -from graphon.file import File -from graphon.graph import Graph -from graphon.nodes.container_effects import ( - ContainerAwaitRequest, - ContainerExecutionResult, - ContainerNodeRunResult, - ContainerValue, - CustomContainerRequest, - build_container_value, -) -from graphon.nodes.start.entities import StartNodeData -from graphon.runtime import RuntimeState, VariablePool -from graphon.runtime.container_state import ( - ContainerFrameState, - CustomContainerFrameState, - CustomContainerRunState, - FrameRuntimeData, -) -from graphon.runtime.execution import ROOT_FRAME_ID -from graphon.variables.segments import ArrayFileSegment -from graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter - -from .workflow_tool_container_types import WorkflowToolContainerPayload - -logger = logging.getLogger(__name__) - -_file_access_controller = DatabaseFileAccessController() -_RESERVED_TOOL_OUTPUTS = frozenset(("text", "json", "files")) -_FAILURE_SELECTOR_PREFIX = "__workflow_tool_container__" -_HIDDEN_CHILD_EVENT_KEY = "__dify_workflow_tool_child__" -_CONTAINER_METADATA_KEYS = frozenset( - ( - WorkflowNodeExecutionMetadataKey.LOOP_ID, - WorkflowNodeExecutionMetadataKey.LOOP_INDEX, - WorkflowNodeExecutionMetadataKey.ITERATION_ID, - WorkflowNodeExecutionMetadataKey.ITERATION_INDEX, - ) -) - -type WorkflowToolEventListenerFactory = Callable[[WorkflowToolSource, str, str], Callable[[NodeEvent], None]] - - -def _save_workflow_tool_event(event: NodeEvent, listeners: Mapping[str, Callable[[NodeEvent], None]]) -> None: - child_event_data = event.node_run_result.process_data.get(_HIDDEN_CHILD_EVENT_KEY) - child_frame_id = child_event_data.get("frame_id") if isinstance(child_event_data, Mapping) else None - if isinstance(child_frame_id, str) and (listener := listeners.get(child_frame_id)) is not None: - try: - listener(event) - except Exception: - logger.exception( - "Failed to persist Workflow Tool event: frame_id=%s, node_id=%s", child_frame_id, event.node_id - ) - - -@final -class WorkflowToolNestedContainerHandler: - """Suppress Workflow Tool events emitted through a nested built-in container.""" - - def __init__( - self, - frame_registry: FrameRegistry, - *, - handler_factory: Callable[[FrameRegistry], ContainerHandler], - hidden_event_listener: Callable[[NodeEvent], None] | None = None, - event_listeners: Mapping[str, Callable[[NodeEvent], None]] | None = None, - ) -> None: - self._handler = handler_factory(frame_registry) - self._hidden_event_listener = hidden_event_listener - self._event_listeners = event_listeners if event_listeners is not None else {} - self.node_type = self._handler.node_type - - def restore_frame(self, frame_state: ContainerFrameState) -> None: - self._handler.restore_frame(frame_state) - - def handle_request(self, *, invocation_id: str, request: ContainerAwaitRequest) -> None: - self._handler.handle_request(invocation_id=invocation_id, request=request) - - def prepare_frame_event(self, *, frame: ExecutionFrame, event: NodeEvent) -> None: - # Source control flow belongs to the Workflow Tool, even when node IDs overlap. - if _HIDDEN_CHILD_EVENT_KEY not in event.node_run_result.process_data: - self._handler.prepare_frame_event(frame=frame, event=event) - - def should_emit(self, *, event: NodeEvent) -> bool: - should_emit = self._handler.should_emit(event=event) - if should_emit and _HIDDEN_CHILD_EVENT_KEY in event.node_run_result.process_data: - _save_workflow_tool_event(event, self._event_listeners) - if self._hidden_event_listener is not None: - self._hidden_event_listener(event) - return False - return should_emit - - def record_frame_failure(self, *, frame: ExecutionFrame, event: NodeRunFailedEvent) -> None: - self._handler.record_frame_failure(frame=frame, event=event) - - def complete_frame_if_ready(self, frame: ExecutionFrame) -> None: - self._handler.complete_frame_if_ready(frame) - - -@final -class WorkflowToolContainerHandler: - """Execute Workflow Tools as dynamic child frames owned by a Tool node.""" - - node_type = BuiltinNodeTypes.TOOL - - def __init__( - self, - frame_registry: FrameRegistry, - *, - sources: Mapping[str, WorkflowToolSource], - hidden_event_listener: Callable[[NodeEvent], None] | None = None, - event_listener_factory: WorkflowToolEventListenerFactory | None = None, - event_listeners: dict[str, Callable[[NodeEvent], None]] | None = None, - execution_context_factory: Callable[[], AbstractContextManager[object]] = nullcontext, - ) -> None: - self._frame_registry = frame_registry - self._sources = sources - self._hidden_event_listener = hidden_event_listener - self._event_listener_factory = event_listener_factory - self._event_listeners = event_listeners if event_listeners is not None else {} - self._execution_context_factory = execution_context_factory - - def restore_frame(self, frame_state: ContainerFrameState) -> None: - if not isinstance(frame_state, CustomContainerFrameState): - raise TypeError(f"Workflow Tool handler cannot restore {frame_state.kind} frame") - variable_pool = frame_state.runtime_data.variable_pool - if isinstance(variable_pool, str): - raise TypeError(f"Workflow Tool frame {frame_state.frame_id} requires a local variable pool") - - run_state = self._get_tool_run_state(frame_state.parent_invocation_id) - payload = WorkflowToolContainerPayload.model_validate_json(run_state.payload) - with self._execution_context_factory(): - self._create_frame( - run_state=run_state, - payload=payload, - frame_id=frame_state.frame_id, - runtime_data=frame_state.runtime_data, - variable_pool=variable_pool.model_copy(deep=True), - ) - - def handle_request( - self, - *, - invocation_id: str, - request: ContainerAwaitRequest, - ) -> None: - if not isinstance(request, CustomContainerRequest): - raise TypeError(f"Workflow Tool handler cannot handle {type(request).__name__}") - - run_state = self._get_tool_run_state(invocation_id) - payload = WorkflowToolContainerPayload.model_validate_json(request.payload) - frame_id = f"{invocation_id}:workflow-tool" - try: - with self._execution_context_factory(): - child_frame = self._create_frame( - run_state=run_state, - payload=payload, - frame_id=frame_id, - ) - except Exception as error: - self._root_runtime_state().enqueue_ready_task( - ResumeTask( - invocation_id=invocation_id, - result=ContainerExecutionResult( - metadata={}, - steps=0, - node_run_result=ContainerNodeRunResult( - status=WorkflowNodeExecutionStatus.FAILED, - inputs={key: build_container_value(value) for key, value in payload.inputs_for_log.items()}, - error=str(error), - error_type=type(error).__name__, - ), - ), - ) - ) - return - self._root_runtime_state().put_container_frame( - CustomContainerFrameState( - frame_id=frame_id, - parent_invocation_id=invocation_id, - runtime_data=child_frame.state.snapshot_frame(copy_variable_pool=False), - ) - ) - child_frame.scheduler.enqueue_node(child_frame.graph.root_node.id) - - def prepare_frame_event(self, *, frame: ExecutionFrame, event: NodeEvent) -> None: - child_event_data = event.node_run_result.process_data.get(_HIDDEN_CHILD_EVENT_KEY) - is_direct_workflow_tool_child = child_event_data is None - if is_direct_workflow_tool_child and isinstance(event, NodeRunFailedEvent): - # Graphon increments immediately after container preparation; only the outer Tool failure belongs here. - frame.state.graph_execution.exceptions_count -= 1 - if isinstance(event, NodeRunPauseRequestedEvent) and isinstance(event.reason, HitlRequired): - # Keep source identity in Graphon; persist form ownership for Dify's response boundary. - _, form_id = default_session_binding.resolve_form_id_from_session_id(session_id=event.reason.session_id) - self._root_runtime_state().variable_pool.add(human_input_container_selector(form_id), frame.container_id) - # Preserve source ownership before outer containers add their own metadata. - event.node_run_result.process_data = { - **event.node_run_result.process_data, - _HIDDEN_CHILD_EVENT_KEY: child_event_data - or { - "frame_id": frame.frame_id, - "container_metadata": { - key: value - for key, value in event.node_run_result.metadata.items() - if key in _CONTAINER_METADATA_KEYS - }, - }, - } - - def should_emit(self, *, event: NodeEvent) -> bool: - _save_workflow_tool_event(event, self._event_listeners) - if self._hidden_event_listener is not None: - self._hidden_event_listener(event) - return False - - def record_frame_failure(self, *, frame: ExecutionFrame, event: NodeRunFailedEvent) -> None: - # ponytail: Graphon custom frames have no metadata slot; scope the pool key to this invocation. - frame.state.variable_pool.add( - self._failure_selector(frame.frame_id), - [event.error, event.node_run_result.error_type], - ) - - def complete_frame_if_ready(self, frame: ExecutionFrame) -> None: - if not frame.scheduler.is_execution_complete(): - return - - frame_state = self._get_tool_frame_state(frame.frame_id) - run_state = self._get_tool_run_state(frame_state.parent_invocation_id) - payload = WorkflowToolContainerPayload.model_validate_json(run_state.payload) - parent_frame = self._frame_registry[run_state.frame_id] - failure_variable = frame.state.variable_pool.get(self._failure_selector(frame.frame_id)) - error = error_type = "" - if failure_variable is not None: - failure = failure_variable.to_object() - if ( - not isinstance(failure, list) - or len(failure) != 2 - or not isinstance(failure[0], str) - or not isinstance(failure[1], str | None) - ): - raise ValueError("Invalid Workflow Tool failure state") - error, error_type = failure - - result = ContainerExecutionResult( - metadata={}, - steps=frame.state.node_run_steps, - node_run_result=ContainerNodeRunResult( - status=WorkflowNodeExecutionStatus.SUCCEEDED - if failure_variable is None - else WorkflowNodeExecutionStatus.FAILED, - inputs={key: build_container_value(value) for key, value in payload.inputs_for_log.items()}, - outputs=self._build_tool_outputs(frame.state.outputs) if failure_variable is None else {}, - error=error, - error_type=error_type, - llm_usage=frame.state.llm_usage, - ), - ) - - parent_frame.state.enqueue_ready_task(ResumeTask(invocation_id=run_state.invocation_id, result=result)) - self._root_runtime_state().pop_container_frame(frame.frame_id) - self._frame_registry.remove(frame.frame_id) - self._event_listeners.pop(frame.frame_id, None) - - def _create_frame( - self, - *, - run_state: CustomContainerRunState, - payload: WorkflowToolContainerPayload, - frame_id: str, - runtime_data: FrameRuntimeData | None = None, - variable_pool: VariablePool | None = None, - ) -> ExecutionFrame: - if payload.call_depth > dify_config.WORKFLOW_CALL_MAX_DEPTH: - raise ValueError(f"Max workflow call depth {dify_config.WORKFLOW_CALL_MAX_DEPTH} reached.") - - parent_frame = self._frame_registry[run_state.frame_id] - parent_node = parent_frame.graph.nodes[run_state.node_id] - run_context = resolve_dify_run_context(parent_node.run_context) - source = self._sources.get(payload.source_workflow_id) - if source is None or source.app_id != payload.source_app_id: - raise ValueError("Workflow Tool source was not found") - graph_config = source.graph_config - root_node_id = get_default_root_node_id(graph_config) - - if variable_pool is None: - variable_pool = self._build_variable_pool( - parent_frame=parent_frame, - source=source, - root_node_id=root_node_id, - payload=payload, - run_context=run_context, - ) - state = self._build_runtime_state( - parent_frame=parent_frame, - variable_pool=variable_pool, - runtime_data=runtime_data, - ) - source_run_context = dict(parent_node.run_context) - source_run_context[DIFY_RUN_CONTEXT_KEY] = run_context.model_copy( - update={"app_id": source.app_id, "workflow_tool_invocation_id": run_state.invocation_id} - ) - parent_factory = parent_frame.graph.node_factory - graph_init_context = DifyGraphInitContext( - workflow_id=source.workflow_id, - graph_config=graph_config, - run_context=source_run_context, - call_depth=payload.call_depth, - execution_driver=parent_factory.execution_driver if isinstance(parent_factory, DifyNodeFactory) else None, - ) - human_input_run_context = ( - parent_factory.human_input_run_context - if isinstance(parent_factory, DifyNodeFactory) - else parent_node.run_context - ) - node_factory = DifyNodeFactory.from_graph_init_context( - graph_init_context=graph_init_context, - runtime_state=state, - human_input_run_context=human_input_run_context, - workflow_tools=parent_factory.workflow_tools if isinstance(parent_factory, DifyNodeFactory) else None, - ) - graph = Graph.init( - graph_config=graph_config, - node_factory=node_factory, - root_node_id=root_node_id, - ) - if self._event_listener_factory is not None: - parent_execution_id = parent_frame.state.graph_execution.get_or_create_node_execution( - frame_id=run_state.frame_id, - node_id=run_state.node_id, - ).execution_id - listener = self._event_listener_factory(source, parent_node.workflow_id, parent_execution_id) - - def save_child_event(event: NodeEvent) -> None: - # Delivery runs after Graphon normalizes results and suppresses retry starts. - event_to_save = event.model_copy(deep=True) - child_event_data = event_to_save.node_run_result.process_data[_HIDDEN_CHILD_EVENT_KEY] - event_to_save.node_run_result.metadata = { - key: value - for key, value in event_to_save.node_run_result.metadata.items() - if key not in _CONTAINER_METADATA_KEYS - } | child_event_data["container_metadata"] - event_to_save.node_run_result.process_data = { - key: value - for key, value in event_to_save.node_run_result.process_data.items() - if key != _HIDDEN_CHILD_EVENT_KEY - } | { - WORKFLOW_TOOL_INVOCATION_ID_KEY: run_state.invocation_id, - } - if event_to_save.container_id == run_state.node_id: - event_to_save.container_id = "" - listener(event_to_save) - - self._event_listeners[frame_id] = save_child_event - return self._frame_registry.create( - frame_id=frame_id, - container_id=run_state.node_id, - graph=graph, - state=state, - ) - - @staticmethod - def _build_runtime_state( - *, - parent_frame: ExecutionFrame, - variable_pool: VariablePool, - runtime_data: FrameRuntimeData | None, - ) -> RuntimeState: - state = RuntimeState( - variable_pool=variable_pool, - start_at=parent_frame.state.start_at, - llm_usage=None if runtime_data is None else runtime_data.llm_usage, - outputs=None if runtime_data is None else dict(runtime_data.outputs), - node_run_steps=0 if runtime_data is None else runtime_data.node_run_steps, - ready_queue=parent_frame.state.ready_queue, - deferred_ready_queue=parent_frame.state.deferred_ready_queue, - graph_execution=parent_frame.state.graph_execution, - ) - if runtime_data is not None: - state.restore_graph_state( - node_states=runtime_data.graph_node_states, - edge_states=runtime_data.graph_edge_states, - ) - return state - - def _build_variable_pool( - self, - *, - parent_frame: ExecutionFrame, - source: WorkflowToolSource, - root_node_id: str, - payload: WorkflowToolContainerPayload, - run_context: DifyRunContext, - ) -> VariablePool: - variable_pool = VariablePool() - for name, value in get_all_system_variables(parent_frame.state.variable_pool).items(): - variable_pool.add(system_variable_selector(name), value) - add_variables_to_pool(variable_pool, source.environment_variables) - - system_files = file_factory.build_from_mappings( - mappings=payload.system_files, - tenant_id=run_context.tenant_id, - config=FileUploadConfigManager.convert(source.features_dict, is_vision=False), - strict_type_validation=run_context.invoke_from == InvokeFrom.SERVICE_API, - access_controller=_file_access_controller, - ) - variable_pool.add(system_variable_selector(SystemVariableKey.APP_ID), source.app_id) - variable_pool.add(system_variable_selector(SystemVariableKey.WORKFLOW_ID), source.workflow_id) - variable_pool.add(system_variable_selector(SystemVariableKey.FILES), list(system_files)) - - root_config = next( - ( - node - for node in source.graph_config.get("nodes", []) - if isinstance(node, Mapping) and node.get("id") == root_node_id - ), - None, - ) - if not isinstance(root_config, Mapping) or not isinstance(root_config.get("data"), Mapping): - raise ValueError(f"Workflow Tool root node {root_node_id} is unavailable") - root_data = StartNodeData.model_validate(root_config["data"]) - inputs = BaseAppGenerator()._prepare_user_inputs( - user_inputs=payload.inputs, - variables=root_data.variables, - tenant_id=run_context.tenant_id, - strict_type_validation=run_context.invoke_from == InvokeFrom.SERVICE_API, - ) - add_node_inputs_to_pool( - variable_pool, - node_id=root_node_id, - inputs=inputs, - aliases=get_compatible_start_aliases( - workflow_kind=source.workflow_kind, - root_node_id=root_node_id, - ), - ) - return variable_pool - - @staticmethod - def _build_tool_outputs(workflow_outputs: Mapping[str, object]) -> dict[str, ContainerValue]: - json_outputs = WorkflowRuntimeTypeConverter().to_json_encodable(workflow_outputs) or {} - tool_outputs: dict[str, Any] = { - **{key: value for key, value in workflow_outputs.items() if key not in _RESERVED_TOOL_OUTPUTS}, - "text": json.dumps(json_outputs, ensure_ascii=False), - "files": ArrayFileSegment(value=WorkflowToolContainerHandler._collect_files(workflow_outputs)), - "json": [json_outputs], - } - return {key: build_container_value(value) for key, value in tool_outputs.items()} - - @staticmethod - def _collect_files(values: Mapping[str, object]) -> list[File]: - files: list[File] = [] - - def collect_files(value: object) -> None: - if isinstance(value, File): - files.append(value) - elif isinstance(value, Mapping): - for item in value.values(): - collect_files(item) - elif isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): - for item in value: - collect_files(item) - - collect_files(values) - return files - - def _root_runtime_state(self) -> RuntimeState: - return self._frame_registry[ROOT_FRAME_ID].state - - @staticmethod - def _failure_selector(frame_id: str) -> tuple[str, str]: - return (f"{_FAILURE_SELECTOR_PREFIX}:{frame_id}", "failure") - - def _get_tool_run_state(self, invocation_id: str) -> CustomContainerRunState: - run_state = self._root_runtime_state().get_container_run(invocation_id) - if not isinstance(run_state, CustomContainerRunState): - raise TypeError(f"Workflow Tool handler cannot use {run_state.kind} run") - return run_state - - def _get_tool_frame_state(self, frame_id: str) -> CustomContainerFrameState: - frame_state = self._root_runtime_state().get_container_frame(frame_id) - if not isinstance(frame_state, CustomContainerFrameState): - raise TypeError(f"Workflow Tool handler cannot use {frame_state.kind} frame") - return frame_state diff --git a/api/repositories/knowledge/retrieval_repository.py b/api/repositories/knowledge/retrieval_repository.py index 774e694c41953a..5825310596f616 100644 --- a/api/repositories/knowledge/retrieval_repository.py +++ b/api/repositories/knowledge/retrieval_repository.py @@ -317,43 +317,80 @@ def record_hits(self, tenant_id: str, documents: list[Document]) -> None: # Process PARENT_CHILD_INDEX text documents - batch fetch ChildChunks if parent_child_text_docs: - index_node_ids = [doc.metadata["doc_id"] for doc, _ in parent_child_text_docs if doc.metadata] - if index_node_ids: - child_chunks_stmt = select(ChildChunk).where(ChildChunk.index_node_id.in_(index_node_ids)) + child_keys = { + (str(dataset_doc.dataset_id), str(dataset_doc.id), str(doc.metadata["doc_id"])) + for doc, dataset_doc in parent_child_text_docs + if doc.metadata and doc.metadata.get("doc_id") + } + if child_keys: + child_chunks_stmt = select(ChildChunk).where( + ChildChunk.tenant_id == tenant_id, + ChildChunk.dataset_id.in_({key[0] for key in child_keys}), + ChildChunk.document_id.in_({key[1] for key in child_keys}), + ChildChunk.index_node_id.in_({key[2] for key in child_keys}), + ) child_chunks = session.scalars(child_chunks_stmt).all() - child_chunk_map = {chunk.index_node_id: chunk.segment_id for chunk in child_chunks} - for doc, _ in parent_child_text_docs: + child_chunk_map = { + (str(chunk.dataset_id), str(chunk.document_id), str(chunk.index_node_id)): chunk.segment_id + for chunk in child_chunks + } + for doc, dataset_doc in parent_child_text_docs: if doc.metadata: - segment_id = child_chunk_map.get(doc.metadata["doc_id"]) + segment_id = child_chunk_map.get( + (str(dataset_doc.dataset_id), str(dataset_doc.id), str(doc.metadata["doc_id"])) + ) if segment_id: segment_ids_to_update.add(str(segment_id)) # Process non-PARENT_CHILD_INDEX text documents - batch fetch DocumentSegments if normal_text_docs: - index_node_ids = [doc.metadata["doc_id"] for doc, _ in normal_text_docs if doc.metadata] - if index_node_ids: - segments_stmt = select(DocumentSegment).where(DocumentSegment.index_node_id.in_(index_node_ids)) + segment_keys = { + (str(dataset_doc.dataset_id), str(dataset_doc.id), str(doc.metadata["doc_id"])) + for doc, dataset_doc in normal_text_docs + if doc.metadata and doc.metadata.get("doc_id") + } + if segment_keys: + segments_stmt = select(DocumentSegment).where( + DocumentSegment.tenant_id == tenant_id, + DocumentSegment.dataset_id.in_({key[0] for key in segment_keys}), + DocumentSegment.document_id.in_({key[1] for key in segment_keys}), + DocumentSegment.index_node_id.in_({key[2] for key in segment_keys}), + ) segments = session.scalars(segments_stmt).all() - segment_map = {seg.index_node_id: seg.id for seg in segments} - for doc, _ in normal_text_docs: + segment_map = { + (str(segment.dataset_id), str(segment.document_id), str(segment.index_node_id)): segment.id + for segment in segments + } + for doc, dataset_doc in normal_text_docs: if doc.metadata: - segment_id = segment_map.get(doc.metadata["doc_id"]) + segment_id = segment_map.get( + (str(dataset_doc.dataset_id), str(dataset_doc.id), str(doc.metadata["doc_id"])) + ) if segment_id: segment_ids_to_update.add(str(segment_id)) # Process IMAGE documents - batch fetch SegmentAttachmentBindings all_image_docs = parent_child_image_docs + normal_image_docs if all_image_docs: - attachment_ids = [ - doc.metadata["doc_id"] for doc, _ in all_image_docs if doc.metadata and doc.metadata.get("doc_id") - ] - if attachment_ids: + attachment_keys = { + (str(dataset_doc.dataset_id), str(dataset_doc.id), str(doc.metadata["doc_id"])) + for doc, dataset_doc in all_image_docs + if doc.metadata and doc.metadata.get("doc_id") + } + if attachment_keys: bindings_stmt = select(SegmentAttachmentBinding).where( SegmentAttachmentBinding.tenant_id == tenant_id, - SegmentAttachmentBinding.attachment_id.in_(attachment_ids), + SegmentAttachmentBinding.dataset_id.in_({key[0] for key in attachment_keys}), + SegmentAttachmentBinding.document_id.in_({key[1] for key in attachment_keys}), + SegmentAttachmentBinding.attachment_id.in_({key[2] for key in attachment_keys}), ) bindings = session.scalars(bindings_stmt).all() - segment_ids_to_update.update(str(binding.segment_id) for binding in bindings) + segment_ids_to_update.update( + str(binding.segment_id) + for binding in bindings + if (str(binding.dataset_id), str(binding.document_id), str(binding.attachment_id)) + in attachment_keys + ) # Batch update hit_count for all segments if segment_ids_to_update: diff --git a/api/services/agent/legacy_workflow_publish_service.py b/api/services/agent/legacy_workflow_publish_service.py index c28a1090bd77aa..8b705f55f1359a 100644 --- a/api/services/agent/legacy_workflow_publish_service.py +++ b/api/services/agent/legacy_workflow_publish_service.py @@ -9,7 +9,6 @@ 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, @@ -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, @@ -115,12 +115,14 @@ def project_draft_bindings_to_graph( @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: diff --git a/api/services/agent/workflow_publish_service.py b/api/services/agent/workflow_publish_service.py index feece95842944a..ad36c812ec8208 100644 --- a/api/services/agent/workflow_publish_service.py +++ b/api/services/agent/workflow_publish_service.py @@ -57,7 +57,7 @@ def __init__( self._validator = WorkflowAgentNodeValidator(repository=repository) def synchronize_draft(self, *, draft_workflow: WorkflowBindingScope, account_id: str) -> set[str]: - retired = self._synchronize_bindings(draft_workflow=draft_workflow, account_id=account_id) + retired = self.synchronize_bindings(draft_workflow=draft_workflow, account_id=account_id) self.validate_agent_nodes_for_draft_sync(draft_workflow=draft_workflow) return retired @@ -210,12 +210,17 @@ def _require_config_asset_refs_resolved_for_publish( f"Workflow Agent node {binding.node_id} has invalid Agent Soul config refs: {'; '.join(missing_refs)}" ) - def _synchronize_bindings( + def synchronize_bindings( self, *, draft_workflow: WorkflowBindingScope, account_id: str, ) -> set[str]: + """Synchronize bindings without validating the complete draft workflow. + + Callers may supplement node jobs in the same transaction before invoking + ``validate_agent_nodes_for_draft_sync``. + """ agent_nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(draft_workflow.graph_dict)) existing_bindings = self._repository.list_bindings(draft_workflow) existing_by_node_id = {binding.node_id: binding for binding in existing_bindings} diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index 0ecded91808f11..606f3537ce0b10 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -36,7 +36,6 @@ resolve_llm_model_config, should_resolve_llm_model_selector, ) -from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator from core.workflow.nodes.knowledge_retrieval.entities import KnowledgeRetrievalNodeData from core.workflow.nodes.trigger_schedule.trigger_schedule_node import TriggerScheduleNode from enums import DeploymentEdition @@ -57,12 +56,14 @@ from models.agent_config_entities import WorkflowNodeJobConfig from models.model import AppModelConfig, AppModelConfigDict, IconType, load_annotation_reply_config from models.workflow import Workflow +from repositories.agent.workflow_binding_repository import WorkflowAgentBindingRepository, workflow_binding_scope from repositories.workflow.definition_repository import WorkflowDefinitionStore from services.agent.dsl_entities import AGENT_NODE_JOB_DSL_KEY, AgentPackage, make_agent_app_dsl from services.agent.dsl_service import AgentDslService -from services.agent.legacy_workflow_publish_service import WorkflowAgentPublishService from services.agent.package_resource_exporter import AgentPackageResourceExporter from services.agent.workflow_contracts import WorkflowAgentBindingStore +from services.agent.workflow_publish_service import WorkflowAgentPublishService +from services.agent.workflow_validator import WorkflowAgentNodeValidator from services.dsl_content import DSL_MAX_SIZE, dsl_content_size from services.dsl_version import check_version_compatibility from services.enterprise import rbac_service @@ -970,8 +971,12 @@ def _workflow_agent_bindings(*, workflow: Workflow, session: Session) -> list[Wo def _sync_copied_workflow_bindings(self, *, workflow: Workflow, account_id: str) -> set[str]: """Reuse roster Agents while retaining node-owned inline materialization and complete node jobs.""" - retirement_candidates = WorkflowAgentPublishService.sync_agent_bindings_for_draft( - session=self._session, draft_workflow=workflow, account_id=account_id + bindings = WorkflowAgentPublishService(repository=WorkflowAgentBindingRepository(self._session)) + # WorkflowDraftService validates after this materializer restores the portable node-job payload. + # synchronize_draft would validate too early, against the default job created for a roster binding. + retirement_candidates = bindings.synchronize_bindings( + draft_workflow=workflow_binding_scope(workflow), + account_id=account_id, ) graph = workflow.graph_dict agent_nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(graph)) @@ -1109,10 +1114,10 @@ def _append_workflow_export_data( graph = workflow_dict.get("graph", {}) reference_nodes: dict[str, dict[str, Any]] = {} if preserve_agent_bindings: - graph = WorkflowAgentPublishService.project_draft_bindings_to_graph( - session=session, - draft_workflow=workflow, - binding_version=workflow.version, + graph = WorkflowAgentPublishService( + repository=WorkflowAgentBindingRepository(session) + ).project_draft_bindings_to_graph( + draft_workflow=workflow_binding_scope(workflow), ) agent_nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(graph)) for binding in cls._workflow_agent_bindings(workflow=workflow, session=session): diff --git a/api/services/tracing/runtime_bridge.py b/api/services/tracing/runtime_bridge.py index 4bbf5ec322da57..eec3d56831c51b 100644 --- a/api/services/tracing/runtime_bridge.py +++ b/api/services/tracing/runtime_bridge.py @@ -19,8 +19,8 @@ from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, WorkflowAppGenerateEntity from core.ops.base_trace_instance import AgentAppTracingContext from core.ops.entities.trace_entity import BaseTraceInfo - from core.workflow.nodes.agent_v2.runtime_request_builder import WorkflowAgentRuntimeBuildContext from services.tracing.service import ApplicationTracingService + from services.workflow.execution.adapters.agent_v2.runtime_request_builder import WorkflowAgentRuntimeBuildContext logger = logging.getLogger(__name__) diff --git a/api/services/workflow/execution/adapters/agent_v2/agent_node.py b/api/services/workflow/execution/adapters/agent_v2/agent_node.py index 45f797638b876c..4c2a1c23186b92 100644 --- a/api/services/workflow/execution/adapters/agent_v2/agent_node.py +++ b/api/services/workflow/execution/adapters/agent_v2/agent_node.py @@ -43,7 +43,13 @@ WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus, ) -from graphon.node_events import NodeEventPayload, NodeRunResult, RunRetrieverResourceEvent, StreamCompletedEvent +from graphon.node_events import ( + NodeEventPayload, + NodeRunResult, + RunRetrieverResourceEvent, + StreamChunkEvent, + StreamCompletedEvent, +) from graphon.nodes.base.node import Node from graphon.nodes.base.variable_template_parser import VariableTemplateParser from models.agent_config_entities import WorkflowNodeJobConfig, WorkflowOutputRoutes @@ -64,7 +70,8 @@ # Stage 4 §5+§7: the terminal events that `_consume_event_stream` may return. -# Stream + started events are filtered out before we yield; transport errors +# Stream + started events are filtered out (plain-run text deltas are yielded +# as stream chunks instead); transport errors # are surfaced as a separate StreamCompletedEvent in the second tuple slot. type _TerminalAgentBackendEvent = ( AgentBackendRunSucceededInternalEvent | AgentBackendRunFailedInternalEvent | AgentBackendRunCancelledInternalEvent @@ -307,11 +314,14 @@ def _run_inner( "status": create_response.status, } - terminal_event, exhausted = self._consume_event_stream( + terminal_event, exhausted = yield from self._consume_event_stream( create_response.run_id, inputs=inputs, process_data=process_data, metadata=metadata, + stream_text=not custom_outputs + and not node_job.output_routes.enabled + and self.error_strategy != ErrorStrategy.DEFAULT_VALUE, ) if metadata.get("knowledge_fs_sources"): yield RunRetrieverResourceEvent(retriever_resources=metadata["knowledge_fs_sources"], context="") @@ -443,12 +453,25 @@ def _consume_event_stream( inputs: dict[str, Any], process_data: dict[str, Any], metadata: dict[str, Any], - ) -> tuple[ - _TerminalAgentBackendEvent | None, - StreamCompletedEvent | None, + stream_text: bool, + ) -> Generator[ + StreamChunkEvent, + None, + tuple[_TerminalAgentBackendEvent | None, StreamCompletedEvent | None], ]: """Consume the SSE stream for one Agent backend run. + When ``stream_text`` is set (plain runs whose final string becomes the + ``text`` output), agent-message deltas are yielded as ``text`` stream + chunks so Answer nodes can stream them. The stream is closed only after + at least one chunk was sent; otherwise the response coordinator falls + back to the final ``text`` variable. Structured runs keep deltas as + metadata because their ``text`` comes from the structured output. + Plain runs with the default-value error strategy stay buffered too: + once a selector has stream chunks, the response filter no longer reads + the variable pool, so a configured fallback ``text`` would be dropped + after a partial stream. + 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. @@ -457,6 +480,30 @@ def _consume_event_stream( terminal may accompany it so the caller can persist the final session snapshot while preserving the original transport failure. """ + result = yield from self._read_event_stream( + run_id, + inputs=inputs, + process_data=process_data, + metadata=metadata, + stream_text=stream_text, + ) + if stream_text and (metadata.get("agent_backend") or {}).get("agent_message_delta_count"): + yield StreamChunkEvent(selector=[self._node_id, "text"], chunk="", is_final=True) + return result + + def _read_event_stream( + self, + run_id: str, + *, + inputs: dict[str, Any], + process_data: dict[str, Any], + metadata: dict[str, Any], + stream_text: bool, + ) -> Generator[ + StreamChunkEvent, + None, + tuple[_TerminalAgentBackendEvent | None, StreamCompletedEvent | None], + ]: stream_event_count = 0 last_event_id: str | None = None try: @@ -477,6 +524,12 @@ def _consume_event_stream( if internal_event.type == AgentBackendInternalEventType.AGENT_MESSAGE_DELTA: if isinstance(internal_event, AgentBackendAgentMessageDeltaInternalEvent): self._record_agent_message_delta_metadata(metadata, internal_event) + if stream_text: + yield StreamChunkEvent( + selector=[self._node_id, "text"], + chunk=internal_event.delta, + is_final=False, + ) continue metadata["agent_backend"] = { **dict(metadata.get("agent_backend") or {}), diff --git a/api/core/app/layers/execution_context_layer.py b/api/services/workflow/execution/adapters/execution_context_layer.py similarity index 100% rename from api/core/app/layers/execution_context_layer.py rename to api/services/workflow/execution/adapters/execution_context_layer.py diff --git a/api/services/workflow/execution/adapters/node_factory.py b/api/services/workflow/execution/adapters/node_factory.py index 12ee166d164735..d95daabd90c4db 100644 --- a/api/services/workflow/execution/adapters/node_factory.py +++ b/api/services/workflow/execution/adapters/node_factory.py @@ -130,24 +130,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", diff --git a/api/services/workflow/execution/adapters/workflow_entry.py b/api/services/workflow/execution/adapters/workflow_entry.py index 80b548465d785d..4caabb80f4a00d 100644 --- a/api/services/workflow/execution/adapters/workflow_entry.py +++ b/api/services/workflow/execution/adapters/workflow_entry.py @@ -16,7 +16,6 @@ build_dify_run_context, ) from core.app.file_access import DatabaseFileAccessController -from core.app.layers.execution_context_layer import ExecutionContextLayer from core.app.workflow.file_runtime import create_dify_workflow_file_runtime from core.app.workflow.layers.observability import ObservabilityLayer from core.credit_usage import CreditUsageAppType @@ -52,6 +51,7 @@ from models.agent_runtime_contracts import WorkflowAgentRuntimeBindings from services.human_input.ports import HumanInputFormReader from services.workflow.contracts import WorkflowSnapshot +from services.workflow.execution.adapters.execution_context_layer import ExecutionContextLayer from services.workflow.execution.adapters.human_input_events import HumanInputFormEventFilter from services.workflow.execution.adapters.node_factory import ( DifyGraphInitContext, diff --git a/api/services/workflow/graph_check.py b/api/services/workflow/graph_check.py index 9365971108df39..566d889f494c1e 100644 --- a/api/services/workflow/graph_check.py +++ b/api/services/workflow/graph_check.py @@ -12,7 +12,6 @@ from core.trigger.constants import TRIGGER_NODE_TYPES from core.workflow.human_input_adapter import adapt_node_config_for_graph -from core.workflow.node_factory import resolve_workflow_node_class from core.workflow.variable_prefixes import ROOT_VARIABLE_NODE_IDS from graphon.entities.graph_config import NodeConfigDictAdapter from graphon.enums import BuiltinNodeTypes @@ -20,6 +19,7 @@ from graphon.variables import VariableBase from models import AppMode from services.credentials.query import CredentialQuery +from services.workflow.execution.adapters.node_factory import resolve_workflow_node_class from services.workflow.node_defaults import complete_sub_models, editor_defaults, node_data_type from services.workflow_service import WorkflowService from services.workflow_variable_reference_validator import SOURCE_HANDLE, branch_handles diff --git a/api/services/workflow/node_defaults.py b/api/services/workflow/node_defaults.py index 5c6c44c7908816..fb7003bad956aa 100644 --- a/api/services/workflow/node_defaults.py +++ b/api/services/workflow/node_defaults.py @@ -10,11 +10,11 @@ from pydantic import BaseModel, Field -from core.workflow.node_factory import resolve_workflow_node_class from graphon.enums import BuiltinNodeTypes from graphon.nodes.base.node import Node from models.human_input_delivery import DeliveryChannelConfig from models.human_input_entities import HumanInputNodeData +from services.workflow.execution.adapters.node_factory import resolve_workflow_node_class # Only empty containers: values the editor reads without a guard and that mean "none yet". EDITOR_EMPTY: Final[Mapping[str, Mapping[str, object]]] = { 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 797d23365b4941..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 @@ -395,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 @@ -405,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 @@ -419,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/core/app/apps/advanced_chat/test_app_generator.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py index 81cf54b7d4bc96..476fe7e09a54a8 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py @@ -28,8 +28,6 @@ from core.app.file_access import DatabaseFileAccessController from core.app.workflow.file_runtime import DifyWorkflowFileRuntime from core.ops.ops_trace_manager import TraceQueueManager -from core.workflow import node_factory as node_factory_module -from core.workflow.node_factory import DifyNodeFactory from core.workflow.system_variables import build_system_variables from graphon.engine import Engine as _pause_resume_Engine from graphon.engine.command import InMemoryChannel @@ -66,11 +64,12 @@ from services.app.generation.adapters.message_cycle import MessageCycleManager from services.app.generation.ports import ConversationSnapshot, MessageSnapshot, WorkflowSnapshot from services.errors.conversation import ConversationNotExistsError +from services.workflow.execution.adapters import node_factory as node_factory_module from services.workflow.execution.adapters.chatflow import app_generator as adv_app_gen_module from services.workflow.execution.adapters.chatflow.app_generator import AdvancedChatAppGenerator from services.workflow.execution.adapters.chatflow.record_preparation import prepare_chat_records +from services.workflow.execution.adapters.node_factory import DifyNodeFactory from services.workflow.execution.ports import WorkflowRuntime -from services.workflow_run_agg import WorkflowRunAgg from tests.helpers.unit.config_override import apply_config_overrides from tests.helpers.unit.model_factories import make_upload_file from tests.workflow_test_utils import build_test_graph_init_params @@ -2002,7 +2001,9 @@ def _node_successes(events: list[EngineEvent]) -> list[str]: return [evt.node_id for evt in events if isinstance(evt, NodeRunSucceededEvent)] -def test_workflow_app_pause_resume_matches_baseline(mocker: MockerFixture): +def test_workflow_app_pause_resume_matches_baseline( + mocker: MockerFixture, *, workflow_runtime: WorkflowRuntime +) -> None: _patch_tool_node(mocker) baseline_state = _build_runtime_state("baseline") @@ -2019,7 +2020,9 @@ def test_workflow_app_pause_resume_matches_baseline(mocker: MockerFixture): resumed_state = RuntimeState.from_snapshot(snapshot) - generator = wf_app_gen_module.WorkflowAppGenerator(execution_driver=WorkflowRunAgg.run) + generator = wf_app_gen_module.WorkflowAppGenerator( + runtime=workflow_runtime, execution_driver=workflow_runtime.execution_driver + ) def _fake_generate(**kwargs): state: RuntimeState = kwargs["graph_runtime_state"] diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner.py index ef46019cbfb67d..a83c05ce3b3da7 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner.py @@ -1182,13 +1182,13 @@ def test_run_uses_single_node_execution_branch( graph=json.dumps({"nodes": [], "edges": []}), ) workflow.environment_variables = [] - snapshot = workflow_snapshot(workflow) + workflow_definition = workflow_snapshot(workflow) runner = WorkflowAppRunner( application_generate_entity=app_generate_entity, queue_manager=queue_manager, variable_loader=MagicMock(), - workflow=snapshot, + workflow=workflow_definition, system_user_id="system-user", runtime=workflow_runtime, ) @@ -1219,7 +1219,7 @@ def test_run_uses_single_node_execution_branch( runner.prepare() prepare_single.assert_called_once_with( - workflow=snapshot, + workflow=workflow_definition, single_iteration_run=single_iteration_run, single_loop_run=single_loop_run, user_id="user", @@ -1336,13 +1336,13 @@ def test_run_adds_inputs_with_snippet_compatible_start_aliases( kind=WorkflowKind.SNIPPET, ) workflow.environment_variables = [] - snapshot = workflow_snapshot(workflow) + workflow_definition = workflow_snapshot(workflow) runner = WorkflowAppRunner( application_generate_entity=app_generate_entity, queue_manager=queue_manager, variable_loader=MagicMock(), - workflow=snapshot, + workflow=workflow_definition, system_user_id="system-user", runtime=workflow_runtime, ) diff --git a/api/tests/unit_tests/core/app/layers/test_execution_context_layer.py b/api/tests/unit_tests/core/app/layers/test_execution_context_layer.py index f2f69dea4a86f6..075d2601d5b963 100644 --- a/api/tests/unit_tests/core/app/layers/test_execution_context_layer.py +++ b/api/tests/unit_tests/core/app/layers/test_execution_context_layer.py @@ -7,10 +7,7 @@ from flask import current_app, g from context.flask_app_context import capture_flask_context -from core.app.layers import execution_context_layer as module -from core.app.layers.execution_context_layer import ExecutionContextLayer from core.app.workflow.file_runtime import create_dify_workflow_file_runtime, init_app -from core.workflow.workflow_tool_container_handler import WorkflowToolContainerHandler from dify_app import DifyApp from graphon.engine import Engine from graphon.engine.layer import Layer @@ -18,6 +15,9 @@ from graphon.entities.base_node_data import BaseNodeData from graphon.file.runtime import peek_workflow_file_runtime, use_workflow_file_runtime from graphon.nodes.base.node import Node +from services.workflow.execution.adapters import execution_context_layer as module +from services.workflow.execution.adapters.execution_context_layer import ExecutionContextLayer +from services.workflow.execution.adapters.workflow_tool_container_handler import WorkflowToolContainerHandler from tests.unit_tests.core.workflow.test_workflow_tool_container_handler import ( _workflow_tool_container_outer_graph as _outer_graph, ) diff --git a/api/tests/unit_tests/core/callback_handler/test_index_tool_callback_handler.py b/api/tests/unit_tests/core/callback_handler/test_index_tool_callback_handler.py index 0bef9b69770c0c..c6df98e2c625f4 100644 --- a/api/tests/unit_tests/core/callback_handler/test_index_tool_callback_handler.py +++ b/api/tests/unit_tests/core/callback_handler/test_index_tool_callback_handler.py @@ -1,278 +1,14 @@ -from collections.abc import Iterator -from dataclasses import dataclass -from uuid import uuid4 - -import pytest from pytest_mock import MockerFixture -from sqlalchemy import select -from sqlalchemy.engine import Engine -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session, SessionTransaction - -import core.callback_handler.index_tool_callback_handler as callback_module -from core.app.entities.app_invoke_entities import InvokeFrom -from core.callback_handler.index_tool_callback_handler import ( - DatasetIndexToolCallbackHandler as DatasetQueryCallbackHandler, -) -from core.rag.index_processor.constant.index_type import IndexStructureType -from core.rag.models.document import Document -from models.dataset import ChildChunk, DatasetQuery, DocumentSegment -from models.dataset import Document as DatasetDocument -from models.enums import CreatorUserRole, DatasetQuerySource, DataSourceType, DocumentCreatedFrom -from services.knowledge.retrieval.adapters.resource_events import ( - DatasetIndexToolCallbackHandler as RetrievalResourceCallbackHandler, -) - - -@dataclass(eq=False, repr=False) -class _DatabaseBinding: - engine: Engine - - -@dataclass(frozen=True) -class _CallerSessionBoundary: - """Caller session state that callback-owned transactions must not disturb.""" - - session: Session - transaction: SessionTransaction - pending_query: DatasetQuery - - -@pytest.fixture -def mock_queue_manager(mocker: MockerFixture): - return mocker.Mock() - - -@pytest.fixture -def handler(mock_queue_manager, sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(callback_module, "db", _DatabaseBinding(sqlite_engine)) - return DatasetQueryCallbackHandler( - queue_manager=mock_queue_manager, - app_id=str(uuid4()), - message_id=str(uuid4()), - user_id=str(uuid4()), - invoke_from=InvokeFrom.DEBUGGER, - ) - - -@pytest.fixture -def caller_session_boundary(sqlite_engine: Engine) -> Iterator[_CallerSessionBoundary]: - """Keep an unflushed caller transaction open to prove callback isolation.""" - - with Session(sqlite_engine, expire_on_commit=False) as session: - pending_query = DatasetQuery( - dataset_id=str(uuid4()), - content="caller-owned pending query", - source=DatasetQuerySource.APP, - source_app_id=str(uuid4()), - created_by_role=CreatorUserRole.ACCOUNT, - created_by=str(uuid4()), - ) - session.add(pending_query) - transaction = session.get_transaction() - assert transaction is not None - - yield _CallerSessionBoundary( - session=session, - transaction=transaction, - pending_query=pending_query, - ) - - assert session.get_transaction() is transaction - assert list(session.new) == [pending_query] - assert not session.dirty - assert not session.deleted - - with Session(sqlite_engine) as observer_session: - assert observer_session.get(DatasetQuery, pending_query.id) is None - - -def _dataset_document(*, doc_form: IndexStructureType) -> DatasetDocument: - return DatasetDocument( - tenant_id=str(uuid4()), - dataset_id=str(uuid4()), - position=1, - data_source_type=DataSourceType.UPLOAD_FILE, - batch="batch", - name="Document", - created_from=DocumentCreatedFrom.API, - created_by=str(uuid4()), - doc_form=doc_form, - ) - - -def _segment(document: DatasetDocument, *, index_node_id: str) -> DocumentSegment: - return DocumentSegment( - tenant_id=document.tenant_id, - dataset_id=document.dataset_id, - document_id=document.id, - position=1, - content="content", - word_count=1, - tokens=1, - created_by=document.created_by, - index_node_id=index_node_id, - hit_count=0, - ) - - -class TestOnQuery: - @pytest.mark.parametrize( - ("invoke_from", "expected_role"), - [ - (InvokeFrom.EXPLORE, "account"), - (InvokeFrom.DEBUGGER, "account"), - (InvokeFrom.WEB_APP, "end_user"), - ], - ) - def test_on_query_success_roles( - self, - monkeypatch: pytest.MonkeyPatch, - sqlite_engine: Engine, - sqlite_session: Session, - caller_session_boundary: _CallerSessionBoundary, - mock_queue_manager, - invoke_from: InvokeFrom, - expected_role: str, - ) -> None: - monkeypatch.setattr(callback_module, "db", _DatabaseBinding(sqlite_engine)) - handler = DatasetQueryCallbackHandler( - queue_manager=mock_queue_manager, - app_id=str(uuid4()), - message_id=str(uuid4()), - user_id=str(uuid4()), - invoke_from=invoke_from, - ) - - handler.on_query("test query", str(uuid4()), caller_session_boundary.session) - - dataset_query = sqlite_session.scalar(select(DatasetQuery)) - assert dataset_query is not None - assert dataset_query.created_by_role == expected_role - - def test_on_query_none_values_roll_back_independent_transaction( - self, - monkeypatch: pytest.MonkeyPatch, - sqlite_engine: Engine, - sqlite_session: Session, - caller_session_boundary: _CallerSessionBoundary, - mock_queue_manager, - ) -> None: - monkeypatch.setattr(callback_module, "db", _DatabaseBinding(sqlite_engine)) - handler = DatasetQueryCallbackHandler( - queue_manager=mock_queue_manager, - app_id=None, - message_id=None, - user_id=None, - invoke_from=None, - ) - - with pytest.raises(IntegrityError): - handler.on_query(None, None, caller_session_boundary.session) # type: ignore[arg-type] - - assert sqlite_session.scalar(select(DatasetQuery)) is None - - -class TestOnToolEnd: - def test_on_tool_end_no_metadata( - self, - handler: DatasetQueryCallbackHandler, - caller_session_boundary: _CallerSessionBoundary, - ) -> None: - document = Document.model_construct(page_content="content", metadata=None, provider="dify") - - handler.on_tool_end([document], caller_session_boundary.session) - - def test_on_tool_end_dataset_document_not_found( - self, - handler: DatasetQueryCallbackHandler, - sqlite_session: Session, - caller_session_boundary: _CallerSessionBoundary, - ) -> None: - document = Document( - page_content="content", - metadata={"document_id": str(uuid4()), "doc_id": "node-1"}, - ) - - handler.on_tool_end([document], caller_session_boundary.session) - - assert sqlite_session.scalar(select(DatasetDocument)) is None - - def test_on_tool_end_parent_child_index_with_child( - self, - handler: DatasetQueryCallbackHandler, - sqlite_session: Session, - caller_session_boundary: _CallerSessionBoundary, - ) -> None: - dataset_document = _dataset_document(doc_form=IndexStructureType.PARENT_CHILD_INDEX) - sqlite_session.add(dataset_document) - sqlite_session.flush() - segment = _segment(dataset_document, index_node_id="parent-node") - sqlite_session.add(segment) - sqlite_session.flush() - child = ChildChunk( - tenant_id=dataset_document.tenant_id, - dataset_id=dataset_document.dataset_id, - document_id=dataset_document.id, - segment_id=segment.id, - position=1, - content="child", - word_count=1, - created_by=dataset_document.created_by, - index_node_id="child-node", - ) - sqlite_session.add(child) - sqlite_session.commit() - document = Document( - page_content="content", - metadata={"document_id": dataset_document.id, "doc_id": child.index_node_id}, - ) - - handler.on_tool_end([document], caller_session_boundary.session) - - sqlite_session.expire_all() - assert sqlite_session.get(DocumentSegment, segment.id).hit_count == 1 # type: ignore[union-attr] - - def test_on_tool_end_non_parent_child_index( - self, - handler: DatasetQueryCallbackHandler, - sqlite_session: Session, - caller_session_boundary: _CallerSessionBoundary, - ) -> None: - dataset_document = _dataset_document(doc_form=IndexStructureType.PARAGRAPH_INDEX) - sqlite_session.add(dataset_document) - sqlite_session.flush() - segment = _segment(dataset_document, index_node_id="node-1") - sqlite_session.add(segment) - sqlite_session.commit() - document = Document( - page_content="content", - metadata={ - "document_id": dataset_document.id, - "doc_id": segment.index_node_id, - "dataset_id": dataset_document.dataset_id, - }, - ) - - handler.on_tool_end([document], caller_session_boundary.session) - - sqlite_session.expire_all() - assert sqlite_session.get(DocumentSegment, segment.id).hit_count == 1 # type: ignore[union-attr] - def test_on_tool_end_empty_documents( - self, - handler: DatasetQueryCallbackHandler, - caller_session_boundary: _CallerSessionBoundary, - ) -> None: - handler.on_tool_end([], caller_session_boundary.session) +from services.knowledge.retrieval.adapters.resource_events import DatasetIndexToolCallbackHandler -class TestReturnRetrieverResourceInfo: - def test_publish_called(self, mock_queue_manager, mocker: MockerFixture): - handler = RetrievalResourceCallbackHandler(mock_queue_manager) - mocker.patch("services.knowledge.retrieval.adapters.resource_events.QueueRetrieverResourcesEvent") - resources = [mocker.Mock()] +def test_return_retriever_resource_info_publishes_event(mocker: MockerFixture) -> None: + mock_queue_manager = mocker.Mock() + handler = DatasetIndexToolCallbackHandler(mock_queue_manager) + mocker.patch("services.knowledge.retrieval.adapters.resource_events.QueueRetrieverResourcesEvent") + resources = [mocker.Mock()] - handler.return_retriever_resource_info(resources) + handler.return_retriever_resource_info(resources) - mock_queue_manager.publish.assert_called_once() + mock_queue_manager.publish.assert_called_once() 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 d5ccfeb0c47bd8..5e348c0c8b346d 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 @@ -51,7 +51,7 @@ WorkflowNodeExecutionStatus, ) from graphon.file import File, FileTransferMethod, FileType -from graphon.node_events import StreamChunkEvent, StreamCompletedEvent +from graphon.node_events import RunRetrieverResourceEvent, StreamChunkEvent, StreamCompletedEvent from graphon.runtime import InitParams, RuntimeState from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment from models.agent import ( @@ -401,12 +401,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( @@ -703,12 +709,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"] @@ -717,6 +726,66 @@ def test_agent_node_records_agent_message_delta_without_text_stream(tool_provide assert agent_backend["agent_message_delta_length"] == len("hello ") +def test_agent_node_streams_text_and_emits_trusted_knowledge_sources(tool_providers) -> None: + from pydantic_ai.messages import FunctionToolResultEvent, ToolReturnPart + + citation = { + "id": "kfs_" + "a" * 32, + "control_space_id": "00000000-0000-4000-8000-000000000001", + "space_name": "Docs", + "node_id": "node", + "document_asset_id": "doc", + "artifact_hash": "hash", + } + + class KnowledgeAndTextBackendClient(FakeAgentBackendRunClient): + def _events(self, run_id: str): + created_at = datetime(2026, 1, 1, tzinfo=UTC) + knowledge_result = ToolReturnPart( + tool_name="knowledge_query", + tool_call_id="call-1", + content={"knowledge_fs_citations": [citation]}, + metadata={"knowledge_fs_citations": [citation]}, + ) + return ( + RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at), + PydanticAIStreamRunEvent( + id="2-0", + run_id=run_id, + created_at=created_at, + data=FunctionToolResultEvent(part=knowledge_result), + ), + PydanticAIStreamRunEvent( + id="3-0", + run_id=run_id, + created_at=created_at, + data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello ")), + agent_message_delta="hello ", + ), + RunSucceededEvent( + id="4-0", + run_id=run_id, + created_at=created_at, + data=RunSucceededEventData( + output={"text": "hello agent"}, + session_snapshot=SessionSnapshot(layers={}), + ), + ), + ) + + events = list(_node(tool_providers=tool_providers, agent_backend_client=KnowledgeAndTextBackendClient())._run()) + + assert events[:2] == [ + StreamChunkEvent(selector=["agent-node", "text"], chunk="hello ", is_final=False), + StreamChunkEvent(selector=["agent-node", "text"], chunk="", is_final=True), + ] + resource_event = cast(RunRetrieverResourceEvent, events[2]) + assert resource_event.retriever_resources[0]["knowledge_fs_citation"]["id"] == citation["id"] + result = cast(StreamCompletedEvent, events[3]).node_run_result + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs == {"text": "hello agent"} + + def test_agent_node_structured_run_keeps_agent_message_delta_out_of_text_stream(tool_providers): events = list( _node( diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py index 8c2924337af91e..034598cabca946 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py @@ -127,6 +127,7 @@ def test_node_binding_resolution_uses_injected_database_through_nested_factories # Single-node debugging must reach the same injected database, # without consulting an installed Flask application container. + from repositories.workflow.definition_repository import workflow_snapshot from services.workflow.execution.adapters.workflow_entry import WorkflowEntry from tests.helpers.unit.model_factories import make_workflow @@ -134,11 +135,13 @@ def test_node_binding_resolution_uses_injected_database_through_nested_factories "id": ids["node_id"], "data": {"type": "agent", "version": "2", "title": "Agent", "agent_node_kind": "dify_agent"}, } - workflow = make_workflow( - tenant_id=ids["tenant_id"], - app_id=ids["app_id"], - workflow_id=ids["workflow_id"], - graph={"nodes": [node_config], "edges": []}, + workflow = workflow_snapshot( + make_workflow( + tenant_id=ids["tenant_id"], + app_id=ids["app_id"], + workflow_id=ids["workflow_id"], + graph={"nodes": [node_config], "edges": []}, + ) ) monkeypatch.setattr(WorkflowEntry, "_run_node_with_layers", Mock()) node, _ = WorkflowEntry.single_step_run( @@ -586,8 +589,12 @@ def test_binding_resolver_uses_pinned_snapshot_for_existing_node_execution( last_statement = orm_statements[-1] assert isinstance(first_statement, ClauseElement) assert isinstance(last_statement, ClauseElement) - assert binding.id in first_statement.compile().params.values() - assert pinned_snapshot.id in last_statement.compile().params.values() + first_params = first_statement.compile().params + last_params = last_statement.compile().params + assert first_params is not None + assert last_params is not None + assert binding.id in first_params.values() + assert pinned_snapshot.id in last_params.values() @pytest.mark.parametrize("sqlite_session", [RESOLVER_MODELS], indirect=True) 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 0c54efb7ae2121..27942ee049d292 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 RuntimeState, 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/test_node_factory.py b/api/tests/unit_tests/core/workflow/test_node_factory.py index bc645143ec3c23..0cc59f175a73c2 100644 --- a/api/tests/unit_tests/core/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/workflow/test_node_factory.py @@ -4,8 +4,6 @@ from unittest.mock import MagicMock, patch, sentinel import pytest -from sqlalchemy import Engine -from sqlalchemy.orm import Session, sessionmaker from core.app.entities.app_invoke_entities import ( DIFY_RUN_CONTEXT_KEY, @@ -17,10 +15,10 @@ from core.plugin.impl.model import PluginModelClient from core.plugin.impl.model_runtime import PluginModelRuntime from core.plugin.plugin_service import PluginService -from core.workflow import node_factory as core_node_factory from core.workflow import template_rendering as workflow_template_rendering from core.workflow.llm_node import DifyLLMNode from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE +from core.workflow.system_variables import build_system_variables from graphon.entities.base_node_data import BaseNodeData from graphon.enums import BuiltinNodeTypes, NodeExecutionType, NodeType from graphon.graph import Graph @@ -42,9 +40,9 @@ ) from graphon.runtime import InitParams, RuntimeState, VariablePool from graphon.variables.segments import ArrayObjectSegment, ObjectSegment, StringSegment -from models.base import TypeBase -from models.model import AppMode, Conversation, ConversationFromSource from services.workflow.execution.adapters import node_factory +from services.workflow.execution.adapters.agent_runtime import AgentRuntimeSupport +from services.workflow.execution.adapters.memory import NodeMemory from services.workflow.execution.adapters.node_factory import DifyNodeFactory as ServiceDifyNodeFactory from services.workflow.execution.adapters.node_factory import get_node_type_classes_mapping from services.workflow.execution.adapters.node_runtime import DifyPreparedLLM @@ -316,21 +314,6 @@ def _get_title(self) -> str: assert legacy_versions.get("latest") is _Version2 -def _persist_conversation(session_maker: sessionmaker[Session]) -> None: - with session_maker.begin() as session: - session.add( - Conversation( - id="conversation-id", - app_id="app-id", - mode=AppMode.ADVANCED_CHAT, - name="Conversation", - _inputs={}, - from_source=ConversationFromSource.API, - from_end_user_id="end-user-id", - ) - ) - - def _assert_constructor_node_data(data, *, node_id: str, node_type: NodeType, version: str = "1") -> None: _ = node_id if isinstance(data, BaseNodeData): @@ -385,16 +368,6 @@ def get_model_schema(self) -> AIModelEntity: class TestNodeFactoryCasesWorkflow: - @staticmethod - @pytest.fixture - def memory_session_maker(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> sessionmaker[Session]: - """Bind node memory lookup to an explicit SQLite session factory.""" - - TypeBase.metadata.create_all(sqlite_engine, tables=[Conversation.__table__]) - session_maker = sessionmaker(sqlite_engine, expire_on_commit=False) - monkeypatch.setattr(core_node_factory.session_factory, "create_session", session_maker) - return session_maker - class TestResolveWorkflowNodeClass: def test_matching_version_uses_registry_mapping(self, monkeypatch) -> None: document_extractor_class = sentinel.document_extractor_class @@ -433,75 +406,83 @@ def test_document_extractor_latest_falls_back_to_registry_mapping(self, monkeypa assert resolved is latest_node_class class TestFetchMemory: - @pytest.mark.parametrize( - ("conversation_id", "memory_config"), - [ - (None, object()), - ("conversation-id", None), - ], - ) - def test_returns_none_when_memory_or_conversation_is_missing(self, conversation_id, memory_config): - result = core_node_factory.fetch_memory( - conversation_id=conversation_id, + def test_returns_none_when_conversation_is_missing(self) -> None: + runtime = MagicMock(spec=WorkflowRuntime) + support = AgentRuntimeSupport(workflow_runtime=runtime) + variables = VariablePool.from_bootstrap(system_variables=build_system_variables(conversation_id=None)) + + result = support.fetch_memory( + variable_pool=variables, + tenant_id="tenant-id", app_id="app-id", - node_data_memory=memory_config, model_instance=sentinel.model_instance, ) assert result is None - def test_returns_none_when_conversation_does_not_exist(self, memory_session_maker: sessionmaker[Session]): - result = core_node_factory.fetch_memory( - conversation_id="conversation-id", - app_id="app-id", - node_data_memory=object(), - model_instance=sentinel.model_instance, + def test_requires_runtime_when_conversation_exists(self) -> None: + variables = VariablePool.from_bootstrap( + system_variables=build_system_variables(conversation_id="conversation-id") ) - assert result is None + with pytest.raises(ValueError, match="Agent memory requires injected execution dependencies"): + AgentRuntimeSupport().fetch_memory( + variable_pool=variables, + tenant_id="tenant-id", + app_id="app-id", + model_instance=sentinel.model_instance, + ) - def test_builds_token_buffer_memory_for_existing_conversation( - self, monkeypatch: pytest.MonkeyPatch, memory_session_maker: sessionmaker[Session] - ): - memory = sentinel.memory - _persist_conversation(memory_session_maker) - token_buffer_memory = MagicMock(return_value=memory) - monkeypatch.setattr(core_node_factory, "TokenBufferMemory", token_buffer_memory) + def test_builds_node_memory_with_injected_history(self) -> None: + runtime = MagicMock(spec=WorkflowRuntime) + runtime.history = sentinel.history + variables = VariablePool.from_bootstrap( + system_variables=build_system_variables(conversation_id="conversation-id") + ) - result = core_node_factory.fetch_memory( - conversation_id="conversation-id", + result = AgentRuntimeSupport(workflow_runtime=runtime).fetch_memory( + variable_pool=variables, + tenant_id="tenant-id", app_id="app-id", - node_data_memory=object(), model_instance=sentinel.model_instance, ) - assert result is memory - loaded_conversation = token_buffer_memory.call_args.kwargs["conversation"] - assert isinstance(loaded_conversation, Conversation) - assert loaded_conversation.id == "conversation-id" - assert token_buffer_memory.call_args.kwargs["model_instance"] is sentinel.model_instance - - def test_uses_configured_session_factory_without_flask_app_context( - self, monkeypatch: pytest.MonkeyPatch, memory_session_maker: sessionmaker[Session] - ): - class RaisingDB: - @property - def engine(self): - raise RuntimeError("Working outside of application context.") - - token_buffer_memory = MagicMock(return_value=sentinel.memory) - _persist_conversation(memory_session_maker) - monkeypatch.setattr(core_node_factory, "db", RaisingDB(), raising=False) - monkeypatch.setattr(core_node_factory, "TokenBufferMemory", token_buffer_memory) + assert isinstance(result, NodeMemory) + assert result.history is sentinel.history + assert result.tenant_id == "tenant-id" + assert result.app_id == "app-id" + assert result.conversation_id == "conversation-id" + assert result.model_instance is sentinel.model_instance - result = core_node_factory.fetch_memory( - conversation_id="conversation-id", + def test_uses_injected_history_without_flask_app_context(self) -> None: + prepared = MagicMock() + prepared.get_prompt_messages.return_value = [sentinel.message] + history = MagicMock() + history.history.return_value = prepared + runtime = MagicMock(spec=WorkflowRuntime) + runtime.history = history + variables = VariablePool.from_bootstrap( + system_variables=build_system_variables(conversation_id="conversation-id") + ) + memory = AgentRuntimeSupport(workflow_runtime=runtime).fetch_memory( + variable_pool=variables, + tenant_id="tenant-id", app_id="app-id", - node_data_memory=object(), model_instance=sentinel.model_instance, ) - assert result is sentinel.memory + assert memory is not None + assert memory.get_history_prompt_messages(max_token_limit=128, message_limit=2) == [sentinel.message] + history.history.assert_called_once_with( + tenant_id="tenant-id", + app_id="app-id", + conversation_id="conversation-id", + message_limit=2, + ) + prepared.get_prompt_messages.assert_called_once_with( + model_instance=sentinel.model_instance, + max_token_limit=128, + ) class TestDifyGraphInitContext: def test_to_graph_init_params_preserves_explicit_values(self): diff --git a/api/tests/unit_tests/core/workflow/test_node_runtime.py b/api/tests/unit_tests/core/workflow/test_node_runtime.py index d40934cbf6ed20..aae7e1498e3f00 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -6,9 +6,8 @@ import time import types import uuid -from collections.abc import Generator, Iterator, Mapping +from collections.abc import Generator, Mapping from dataclasses import replace -from datetime import UTC, datetime from decimal import Decimal from types import SimpleNamespace from typing import cast, override @@ -16,11 +15,14 @@ from uuid import uuid4 import pytest -from sqlalchemy import Engine, event -from sqlalchemy.orm import Session, sessionmaker from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom -from core.app.file_access import FileAccessScope, bind_file_access_scope, grant_retriever_segment_access +from core.app.file_access import ( + FileAccessScope, + bind_file_access_scope, + get_current_file_access_scope, + grant_retriever_segment_access, +) from core.app.llm.model_access import DifyCredentialsProvider, DifyModelFactory from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler from core.helper.ssrf_proxy import graphon_ssrf_proxy @@ -36,9 +38,7 @@ from core.tools.errors import ToolInvokeError from core.tools.utils.configuration import ToolParameterConfigurationManager from core.tools.utils.message_transformer import ToolFileMessageTransformer -from core.workflow import node_runtime as core_node_runtime from core.workflow.file_reference import parse_file_reference -from core.workflow.node_runtime import DifyRetrieverAttachmentLoader from core.workflow.system_variables import build_system_variables from enums.human_input import DeliveryMethodType from extensions.application_services.workflow import WorkflowExecutionDependencies @@ -71,9 +71,6 @@ from graphon.nodes.tool_runtime_entities import ToolRuntimeHandle, ToolRuntimeMessage from graphon.runtime import RuntimeState, VariablePool from graphon.variables.segments import ArrayFileSegment, FileSegment -from models.base import TypeBase -from models.dataset import SegmentAttachmentBinding -from models.enums import CreatorUserRole from models.human_input_delivery import ( EmailDeliveryConfig, EmailDeliveryMethod, @@ -82,8 +79,8 @@ _InteractiveSurfaceDeliveryConfig, ) from models.human_input_entities import FileInputConfig, FileListInputConfig, HumanInputNodeData -from models.model import StorageType, UploadFile from models.tools import ToolFile +from services.knowledge.entities.segments import SegmentAttachmentRecord from services.tools.tool_engine import ToolEngine from services.tools.tool_manager import ToolManager from services.workflow.execution.adapters import node_runtime @@ -94,6 +91,7 @@ DifyPreparedLLM, DifyPreparedPollingLLM, DifyPromptMessageSerializer, + DifyRetrieverAttachmentLoader, DifyToolFileManager, DifyToolNodeRuntime, apply_dify_debug_email_recipient, @@ -1421,44 +1419,16 @@ def test_map_invocation_exception_normalizes_runtime_errors( assert str(error) == message -def _persist_attachment( - session: Session, - *, - segment_id: str, - upload_file_id: str, - upload_file_tenant_id: str = "tenant-id", -) -> UploadFile: - """Persist an attachment binding for the test tenant and its referenced upload file.""" - - upload_file = UploadFile( - tenant_id=upload_file_tenant_id, - storage_type=StorageType.LOCAL, - key="storage-key", +def _attachment_record(file_id: str = "upload-file-id") -> SegmentAttachmentRecord: + return SegmentAttachmentRecord( + id=file_id, name="diagram.png", size=128, extension="png", mime_type="image/png", - created_by_role=CreatorUserRole.ACCOUNT, - created_by="user-id", - created_at=datetime.now(UTC).replace(tzinfo=None), - used=False, source_url="https://example.com/diagram.png", + storage_key="storage-key", ) - upload_file.id = upload_file_id - session.add_all( - [ - upload_file, - SegmentAttachmentBinding( - tenant_id="tenant-id", - dataset_id="dataset-id", - document_id="document-id", - segment_id=segment_id, - attachment_id=upload_file_id, - ), - ] - ) - session.commit() - return upload_file def _build_model_schema(*, features: list[ModelFeature] | None = None) -> AIModelEntity: @@ -1534,15 +1504,6 @@ def _build_email_method(*, debug_mode: bool = False) -> EmailDeliveryMethod: class TestNodeRuntimeCases: - @staticmethod - @pytest.fixture - def attachment_session(sqlite_engine: Engine) -> Iterator[Session]: - """Provide real attachment and upload-file persistence to node runtime tests.""" - - TypeBase.metadata.create_all(sqlite_engine, tables=[SegmentAttachmentBinding.__table__, UploadFile.__table__]) - with Session(sqlite_engine, expire_on_commit=False) as session: - yield session - @staticmethod def test_resolve_dify_run_context_accepts_mapping_payload() -> None: run_context = resolve_dify_run_context(_build_run_context()) @@ -1961,43 +1922,36 @@ def test_dify_prompt_message_serializer_delegates(monkeypatch: pytest.MonkeyPatc ) @staticmethod - def test_dify_retriever_attachment_loader_builds_graph_files( - monkeypatch: pytest.MonkeyPatch, attachment_session: Session - ) -> None: - _persist_attachment(attachment_session, segment_id="segment-id", upload_file_id="upload-file-id") - build_from_mapping = MagicMock(return_value=sentinel.file) - monkeypatch.setattr(core_node_runtime, "db", SimpleNamespace(engine=attachment_session.get_bind())) - loader = DifyRetrieverAttachmentLoader( - file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping) - ) + def test_dify_retriever_attachment_loader_builds_graph_files(monkeypatch: pytest.MonkeyPatch) -> None: + load_attachments = MagicMock(return_value=[_attachment_record()]) + monkeypatch.setattr(node_runtime, "sign_upload_file_url", lambda *, upload_file_id: f"signed:{upload_file_id}") + loader = DifyRetrieverAttachmentLoader(load_attachments=load_attachments) files = loader.load(segment_id="segment-id") - assert files == [sentinel.file] - build_from_mapping.assert_called_once() - mapping = build_from_mapping.call_args.kwargs["mapping"] - assert mapping["id"] == "upload-file-id" - assert mapping["transfer_method"] == FileTransferMethod.LOCAL_FILE - assert mapping["type"] == FileType.IMAGE - assert parse_file_reference(mapping["reference"]).storage_key is None + assert len(files) == 1 + assert files[0].id == "upload-file-id" + assert files[0].filename == "diagram.png" + assert files[0].extension == ".png" + assert files[0].transfer_method == FileTransferMethod.LOCAL_FILE + assert files[0].type == FileType.IMAGE + assert files[0].remote_url == "signed:upload-file-id" + reference = parse_file_reference(files[0].reference) + assert reference is not None + assert reference.record_id == "upload-file-id" + assert reference.storage_key is None + load_attachments.assert_called_once_with("segment-id") @staticmethod def test_dify_retriever_attachment_loader_grants_upload_files_for_allowed_segment( monkeypatch: pytest.MonkeyPatch, - attachment_session: Session, ) -> None: - from factories.file_factory import builders as file_builders - upload_file_id = str(uuid4()) segment_id = str(uuid4()) - _persist_attachment(attachment_session, segment_id=segment_id, upload_file_id=upload_file_id) - engine = attachment_session.get_bind() - assert engine is not None - monkeypatch.setattr(core_node_runtime, "db", SimpleNamespace(engine=engine)) - session_maker = sessionmaker(engine, expire_on_commit=False) - monkeypatch.setattr(file_builders.session_factory, "create_session", session_maker) - - loader = DifyRetrieverAttachmentLoader(file_reference_factory=DifyFileReferenceFactory(_build_run_context())) + monkeypatch.setattr(node_runtime, "sign_upload_file_url", lambda *, upload_file_id: upload_file_id) + loader = DifyRetrieverAttachmentLoader( + load_attachments=lambda _segment_id: [_attachment_record(upload_file_id)] + ) scope = FileAccessScope( tenant_id="tenant-id", user_id="end-user-id", @@ -2008,33 +1962,18 @@ def test_dify_retriever_attachment_loader_grants_upload_files_for_allowed_segmen with bind_file_access_scope(scope): grant_retriever_segment_access([segment_id]) files = loader.load(segment_id=segment_id) + active_scope = get_current_file_access_scope() - assert files[0].related_id == upload_file_id + assert files[0].id == upload_file_id assert files[0].filename == "diagram.png" + assert active_scope is not None + assert active_scope.granted_upload_file_ids == frozenset({upload_file_id}) @staticmethod - def test_dify_retriever_attachment_loader_rejects_granted_upload_file_from_another_tenant( - monkeypatch: pytest.MonkeyPatch, - attachment_session: Session, - ) -> None: - from factories.file_factory import builders as file_builders - - upload_file_id = str(uuid4()) + def test_dify_retriever_attachment_loader_honors_the_scoped_attachment_reader() -> None: segment_id = str(uuid4()) - _persist_attachment( - attachment_session, - segment_id=segment_id, - upload_file_id=upload_file_id, - upload_file_tenant_id="other-tenant-id", - ) - engine = attachment_session.get_bind() - assert engine is not None - monkeypatch.setattr(core_node_runtime, "db", SimpleNamespace(engine=engine)) - monkeypatch.setattr( - file_builders.session_factory, "create_session", sessionmaker(engine, expire_on_commit=False) - ) - - loader = DifyRetrieverAttachmentLoader(file_reference_factory=DifyFileReferenceFactory(_build_run_context())) + load_attachments = MagicMock(return_value=[]) + loader = DifyRetrieverAttachmentLoader(load_attachments=load_attachments) scope = FileAccessScope( tenant_id="tenant-id", user_id="end-user-id", @@ -2044,28 +1983,14 @@ def test_dify_retriever_attachment_loader_rejects_granted_upload_file_from_anoth with bind_file_access_scope(scope): grant_retriever_segment_access([segment_id]) - with pytest.raises(ValueError, match="Invalid upload file"): - loader.load(segment_id=segment_id) - - @staticmethod - def test_dify_retriever_attachment_loader_skips_ungranted_segment_for_end_user( - monkeypatch: pytest.MonkeyPatch, - attachment_session: Session, - ) -> None: - build_from_mapping = MagicMock() - engine = attachment_session.get_bind() - assert engine is not None - monkeypatch.setattr(core_node_runtime, "db", SimpleNamespace(engine=engine)) - statement_count = 0 + assert loader.load(segment_id=segment_id) == [] - def count_statements(*_args, **_kwargs) -> None: - nonlocal statement_count - statement_count += 1 + load_attachments.assert_called_once_with(segment_id) - event.listen(engine, "before_cursor_execute", count_statements) - loader = DifyRetrieverAttachmentLoader( - file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping) - ) + @staticmethod + def test_dify_retriever_attachment_loader_skips_ungranted_segment_for_end_user() -> None: + load_attachments = MagicMock(return_value=[_attachment_record()]) + loader = DifyRetrieverAttachmentLoader(load_attachments=load_attachments) scope = FileAccessScope( tenant_id="tenant-id", user_id="end-user-id", @@ -2076,33 +2001,16 @@ def count_statements(*_args, **_kwargs) -> None: with bind_file_access_scope(scope): files = loader.load(segment_id=str(uuid4())) - try: - assert files == [] - assert statement_count == 0 - build_from_mapping.assert_not_called() - finally: - event.remove(engine, "before_cursor_execute", count_statements) + assert files == [] + load_attachments.assert_not_called() @staticmethod - def test_dify_retriever_attachment_loader_skips_segment_rejected_by_checker( - monkeypatch: pytest.MonkeyPatch, - attachment_session: Session, - ) -> None: + def test_dify_retriever_attachment_loader_skips_segment_rejected_by_checker() -> None: segment_id = str(uuid4()) - build_from_mapping = MagicMock() + load_attachments = MagicMock(return_value=[_attachment_record()]) segment_access_checker = MagicMock(return_value=False) - engine = attachment_session.get_bind() - assert engine is not None - monkeypatch.setattr(core_node_runtime, "db", SimpleNamespace(engine=engine)) - statement_count = 0 - - def count_statements(*_args, **_kwargs) -> None: - nonlocal statement_count - statement_count += 1 - - event.listen(engine, "before_cursor_execute", count_statements) loader = DifyRetrieverAttachmentLoader( - file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping), + load_attachments=load_attachments, segment_access_checker=segment_access_checker, ) scope = FileAccessScope( @@ -2116,13 +2024,9 @@ def count_statements(*_args, **_kwargs) -> None: grant_retriever_segment_access([segment_id]) files = loader.load(segment_id=segment_id) - try: - assert files == [] - segment_access_checker.assert_called_once_with(segment_id) - assert statement_count == 0 - build_from_mapping.assert_not_called() - finally: - event.remove(engine, "before_cursor_execute", count_statements) + assert files == [] + segment_access_checker.assert_called_once_with(segment_id) + load_attachments.assert_not_called() @staticmethod def test_dify_tool_file_manager_resolves_conversation_id_for_tool_files(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/api/tests/unit_tests/core/workflow/test_workflow_tool_container_handler.py b/api/tests/unit_tests/core/workflow/test_workflow_tool_container_handler.py index 7bfb61fe2c25b2..e415d6cde3449f 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_tool_container_handler.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_tool_container_handler.py @@ -27,8 +27,6 @@ from core.app.apps.workflow.app_config_manager import WorkflowAppConfig from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, WorkflowAppGenerateEntity from core.app.file_access import FileAccessScope, bind_file_access_scope -from core.app.layers import execution_context_layer as module -from core.app.layers.execution_context_layer import ExecutionContextLayer from core.app.workflow.file_runtime import init_app from core.app.workflow.layers.observability import ObservabilityLayer from core.helper.code_executor.code_executor import CodeExecutionError @@ -45,10 +43,7 @@ from core.workflow.node_execution_process_data import ( WORKFLOW_TOOL_INVOCATION_ID_KEY, ) -from core.workflow.nodes.human_input.boundary import resolve_human_input_node_id -from core.workflow.nodes.human_input.callback import DifyHITLCallback from core.workflow.system_variables import SystemVariableKey, system_variable_selector -from core.workflow.workflow_entry import WorkflowEntry from core.workflow.workflow_tool_container_types import WorkflowToolContainerPayload from core.workflow.workflow_tool_node import DifyWorkflowToolNode from dify_app import DifyApp @@ -137,8 +132,13 @@ from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom from repositories.agent.runtime_repository import WorkflowAgentBindingResolver from services.tools.workflow.tool import WorkflowTool +from services.workflow.execution.adapters import execution_context_layer as module +from services.workflow.execution.adapters.execution_context_layer import ExecutionContextLayer +from services.workflow.execution.adapters.human_input import DifyHITLCallback +from services.workflow.execution.adapters.human_input_events import resolve_human_input_node_id from services.workflow.execution.adapters.node_factory import DifyNodeFactory from services.workflow.execution.adapters.node_runtime import DifyToolFileManager, DifyToolNodeRuntime +from services.workflow.execution.adapters.workflow_entry import WorkflowEntry from services.workflow.execution.adapters.workflow_tool_container_handler import ( WorkflowToolContainerHandler, WorkflowToolNestedContainerHandler, diff --git a/api/tests/unit_tests/repositories/knowledge/test_retrieval_repository.py b/api/tests/unit_tests/repositories/knowledge/test_retrieval_repository.py new file mode 100644 index 00000000000000..1d8452262e7f72 --- /dev/null +++ b/api/tests/unit_tests/repositories/knowledge/test_retrieval_repository.py @@ -0,0 +1,248 @@ +import json +from collections.abc import Iterator +from uuid import uuid4 + +import pytest +from sqlalchemy import select +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from core.rag.index_processor.constant.index_type import IndexStructureType +from core.rag.models.document import Document +from models.dataset import ChildChunk, Dataset, DatasetQuery, DocumentSegment +from models.dataset import Document as DatasetDocument +from models.enums import CreatorUserRole, DatasetQuerySource, DataSourceType, DocumentCreatedFrom +from repositories.knowledge.retrieval_repository import KnowledgeRetrievalRepository + + +@pytest.fixture +def repository(sqlite_session_factory: sessionmaker[Session]) -> KnowledgeRetrievalRepository: + return KnowledgeRetrievalRepository(sqlite_session_factory) + + +@pytest.fixture(autouse=True) +def caller_session_boundary(sqlite_engine: Engine) -> Iterator[None]: + """Prove every repository operation leaves the caller's transaction intact.""" + + with Session(sqlite_engine, expire_on_commit=False) as session: + pending_query = DatasetQuery( + dataset_id=str(uuid4()), + content="caller-owned pending query", + source=DatasetQuerySource.APP, + source_app_id=str(uuid4()), + created_by_role=CreatorUserRole.ACCOUNT, + created_by=str(uuid4()), + ) + session.add(pending_query) + transaction = session.get_transaction() + assert transaction is not None + + yield + + assert session.get_transaction() is transaction + assert list(session.new) == [pending_query] + assert not session.dirty + assert not session.deleted + + with Session(sqlite_engine) as observer_session: + assert observer_session.get(DatasetQuery, pending_query.id) is None + + +def _dataset(*, tenant_id: str) -> Dataset: + return Dataset(tenant_id=tenant_id, name="Dataset", created_by=str(uuid4())) + + +def _dataset_document(*, tenant_id: str, dataset_id: str, doc_form: IndexStructureType) -> DatasetDocument: + return DatasetDocument( + tenant_id=tenant_id, + dataset_id=dataset_id, + position=1, + data_source_type=DataSourceType.UPLOAD_FILE, + batch="batch", + name="Document", + created_from=DocumentCreatedFrom.API, + created_by=str(uuid4()), + doc_form=doc_form, + ) + + +def _segment(document: DatasetDocument, *, index_node_id: str) -> DocumentSegment: + return DocumentSegment( + tenant_id=document.tenant_id, + dataset_id=document.dataset_id, + document_id=document.id, + position=1, + content="content", + word_count=1, + tokens=1, + created_by=document.created_by, + index_node_id=index_node_id, + hit_count=0, + ) + + +@pytest.mark.parametrize( + ("role", "query", "attachment_ids", "expected_content"), + [ + (CreatorUserRole.ACCOUNT, "test query", None, {"content_type": "text_query", "content": "test query"}), + (CreatorUserRole.END_USER, "test query", None, {"content_type": "text_query", "content": "test query"}), + (CreatorUserRole.ACCOUNT, None, ["attachment-1"], {"content_type": "image_query", "content": "attachment-1"}), + ], + ids=["account-text", "end-user-text", "account-image"], +) +def test_record_queries_uses_owned_transaction_and_tenant_scope( + repository: KnowledgeRetrievalRepository, + sqlite_session: Session, + role: CreatorUserRole, + query: str | None, + attachment_ids: list[str] | None, + expected_content: dict[str, str], +) -> None: + tenant_id = str(uuid4()) + owned = _dataset(tenant_id=tenant_id) + foreign = _dataset(tenant_id=str(uuid4())) + sqlite_session.add_all([owned, foreign]) + sqlite_session.commit() + + repository.record_queries( + tenant_id, + query=query, + attachment_ids=attachment_ids, + dataset_ids=[owned.id, foreign.id], + app_id=str(uuid4()), + role=role, + user_id=str(uuid4()), + ) + + queries = sqlite_session.scalars(select(DatasetQuery)).all() + assert len(queries) == 1 + assert queries[0].dataset_id == owned.id + assert queries[0].created_by_role == role + assert json.loads(queries[0].content) == [expected_content] + + +def test_record_queries_without_content_keeps_caller_transaction( + repository: KnowledgeRetrievalRepository, + sqlite_session: Session, +) -> None: + repository.record_queries( + str(uuid4()), + query=None, + attachment_ids=None, + dataset_ids=[str(uuid4())], + app_id=str(uuid4()), + role=CreatorUserRole.ACCOUNT, + user_id=str(uuid4()), + ) + + assert sqlite_session.scalar(select(DatasetQuery)) is None + + +def test_record_hits_ignores_document_without_metadata( + repository: KnowledgeRetrievalRepository, +) -> None: + document = Document.model_construct(page_content="content", metadata=None, provider="dify") + + repository.record_hits(str(uuid4()), [document]) + + +def test_record_hits_ignores_missing_dataset_document( + repository: KnowledgeRetrievalRepository, + sqlite_session: Session, +) -> None: + document = Document(page_content="content", metadata={"document_id": str(uuid4()), "doc_id": "node-1"}) + + repository.record_hits(str(uuid4()), [document]) + + assert sqlite_session.scalar(select(DatasetDocument)) is None + + +def test_record_hits_parent_child_keys_include_dataset_and_document( + repository: KnowledgeRetrievalRepository, + sqlite_session: Session, +) -> None: + tenant_id = str(uuid4()) + dataset_documents = [ + _dataset_document( + tenant_id=tenant_id, + dataset_id=str(uuid4()), + doc_form=IndexStructureType.PARENT_CHILD_INDEX, + ) + for _ in range(2) + ] + sqlite_session.add_all(dataset_documents) + sqlite_session.flush() + segments = [_segment(document, index_node_id=f"parent-{index}") for index, document in enumerate(dataset_documents)] + sqlite_session.add_all(segments) + sqlite_session.flush() + sqlite_session.add_all( + ChildChunk( + tenant_id=document.tenant_id, + dataset_id=document.dataset_id, + document_id=document.id, + segment_id=segment.id, + position=1, + content="child", + word_count=1, + created_by=document.created_by, + index_node_id="shared-child-node", + ) + for document, segment in zip(dataset_documents, segments, strict=True) + ) + sqlite_session.commit() + + repository.record_hits( + tenant_id, + [ + Document( + page_content="content", + metadata={"document_id": document.id, "doc_id": "shared-child-node"}, + ) + for document in dataset_documents + ], + ) + + sqlite_session.expire_all() + assert [sqlite_session.get(DocumentSegment, segment.id).hit_count for segment in segments] == [1, 1] # type: ignore[union-attr] + + +def test_record_hits_segment_keys_include_dataset_and_document( + repository: KnowledgeRetrievalRepository, + sqlite_session: Session, +) -> None: + tenant_id = str(uuid4()) + dataset_documents = [ + _dataset_document( + tenant_id=tenant_id, + dataset_id=str(uuid4()), + doc_form=IndexStructureType.PARAGRAPH_INDEX, + ) + for _ in range(2) + ] + sqlite_session.add_all(dataset_documents) + sqlite_session.flush() + segments = [_segment(document, index_node_id="shared-node") for document in dataset_documents] + sqlite_session.add_all(segments) + sqlite_session.commit() + + repository.record_hits( + tenant_id, + [ + Document( + page_content="content", + metadata={ + "document_id": document.id, + "doc_id": "shared-node", + "dataset_id": document.dataset_id, + }, + ) + for document in dataset_documents + ], + ) + + sqlite_session.expire_all() + assert [sqlite_session.get(DocumentSegment, segment.id).hit_count for segment in segments] == [1, 1] # type: ignore[union-attr] + + +def test_record_hits_accepts_empty_documents(repository: KnowledgeRetrievalRepository) -> None: + repository.record_hits(str(uuid4()), []) diff --git a/api/tests/unit_tests/services/agent/test_roster_service.py b/api/tests/unit_tests/services/agent/test_roster_service.py index 1221bdd254d611..f63f4a7df7c752 100644 --- a/api/tests/unit_tests/services/agent/test_roster_service.py +++ b/api/tests/unit_tests/services/agent/test_roster_service.py @@ -5950,7 +5950,7 @@ def _sync_roster_agent_task_refs( session.add(existing_binding) session.commit() - build_workflow_agent_service(session)._synchronize_bindings( + build_workflow_agent_service(session).synchronize_bindings( draft_workflow=workflow_binding_scope(workflow), account_id="account-1", ) @@ -6344,7 +6344,7 @@ def test_creates_roster_binding_from_agent_node_graph(self, sqlite_session: Sess session.add_all([agent, self._publish_revision()]) session.commit() - build_workflow_agent_service(session)._synchronize_bindings( + build_workflow_agent_service(session).synchronize_bindings( draft_workflow=workflow_binding_scope(workflow), account_id="account-1", ) @@ -6468,7 +6468,7 @@ def test_creates_inline_binding_from_agent_node_graph(self, sqlite_session: Sess session.add_all([agent, snapshot]) session.commit() - build_workflow_agent_service(session)._synchronize_bindings( + build_workflow_agent_service(session).synchronize_bindings( draft_workflow=workflow_binding_scope(workflow), account_id="account-1", ) @@ -6524,7 +6524,7 @@ def test_keeps_pending_inline_binding_in_draft_graph_without_db_binding(self, sq session.add(existing_binding) session.commit() - build_workflow_agent_service(session)._synchronize_bindings( + build_workflow_agent_service(session).synchronize_bindings( draft_workflow=workflow_binding_scope(workflow), account_id="account-1", ) @@ -6591,7 +6591,7 @@ def test_clones_inline_binding_for_agent_owned_by_another_node( ), ) - build_workflow_agent_service(session)._synchronize_bindings( + build_workflow_agent_service(session).synchronize_bindings( draft_workflow=workflow_binding_scope(workflow), account_id="account-1", ) @@ -6632,7 +6632,7 @@ def test_rejects_agent_node_graph_binding_with_unsupported_type(self, sqlite_ses ) with pytest.raises(ValueError, match="unsupported agent_binding type"): - build_workflow_agent_service(session)._synchronize_bindings( + build_workflow_agent_service(session).synchronize_bindings( draft_workflow=workflow_binding_scope(workflow), account_id="account-1", ) @@ -6664,7 +6664,7 @@ def test_treats_partial_inline_binding_as_pending_draft_state(self, sqlite_sessi ), ) - build_workflow_agent_service(session)._synchronize_bindings( + build_workflow_agent_service(session).synchronize_bindings( draft_workflow=workflow_binding_scope(workflow), account_id="account-1", ) @@ -6715,7 +6715,7 @@ def test_rejects_inline_binding_with_missing_snapshot(self, sqlite_session: Sess session.commit() with pytest.raises(ValueError, match="missing inline agent config snapshot"): - build_workflow_agent_service(session)._synchronize_bindings( + build_workflow_agent_service(session).synchronize_bindings( draft_workflow=workflow_binding_scope(workflow), account_id="account-1", ) @@ -6777,7 +6777,7 @@ def test_updates_existing_roster_binding_prompt_from_agent_node_graph(self, sqli session.add_all([agent, self._publish_revision(), existing_binding]) session.commit() - build_workflow_agent_service(session)._synchronize_bindings( + build_workflow_agent_service(session).synchronize_bindings( draft_workflow=workflow_binding_scope(workflow), account_id="account-1", ) @@ -6849,7 +6849,7 @@ def test_updates_existing_roster_binding_declared_outputs_from_agent_node_graph( session.add_all([agent, self._publish_revision(), existing_binding]) session.commit() - build_workflow_agent_service(session)._synchronize_bindings( + build_workflow_agent_service(session).synchronize_bindings( draft_workflow=workflow_binding_scope(workflow), account_id="account-1", ) @@ -7049,7 +7049,7 @@ def test_deletes_draft_binding_and_returns_only_replaced_inline_agent( ] ) sqlite_session.commit() - retirement_candidates = build_workflow_agent_service(sqlite_session)._synchronize_bindings( + retirement_candidates = build_workflow_agent_service(sqlite_session).synchronize_bindings( draft_workflow=workflow_binding_scope(workflow), account_id="account-1", ) diff --git a/api/tests/unit_tests/services/agent/test_workflow_publish_service.py b/api/tests/unit_tests/services/agent/test_workflow_publish_service.py index 53361d7dd13bd7..b7dd185abb2ddb 100644 --- a/api/tests/unit_tests/services/agent/test_workflow_publish_service.py +++ b/api/tests/unit_tests/services/agent/test_workflow_publish_service.py @@ -114,13 +114,11 @@ def test_draft_sync_resolves_roster_agents(sqlite_session: Session) -> None: roster_snapshots.append(_snapshot(snapshot_id=f"{agent_id}-snapshot", agent_id=agent_id)) sqlite_session.add_all([*roster_agents, *roster_snapshots]) sqlite_session.commit() - - retirement_candidates = build_workflow_agent_service(sqlite_session)._synchronize_bindings( + build_workflow_agent_service(sqlite_session).synchronize_bindings( draft_workflow=workflow_binding_scope(draft_workflow), account_id="account-1", ) - assert retirement_candidates == set() bindings = { binding.node_id: binding for binding in sqlite_session.scalars( @@ -573,7 +571,7 @@ def test_output_routes_round_trip_through_sync_projection_and_publication(sqlite } workflow = _workflow() workflow.graph = json.dumps({"nodes": [{"id": "route-node", "data": node_data}], "edges": []}) - build_workflow_agent_service(sqlite_session)._synchronize_bindings( + build_workflow_agent_service(sqlite_session).synchronize_bindings( draft_workflow=workflow_binding_scope(workflow), account_id="account-1" ) projected = build_workflow_agent_service(sqlite_session).project_draft_bindings_to_graph( @@ -592,7 +590,7 @@ def test_output_routes_round_trip_through_sync_projection_and_publication(sqlite assert WorkflowNodeJobConfig.model_validate(frozen.node_job_config_dict).output_routes.model_dump() == routes node_data["agent_output_routes"] = {"enabled": False, "routes": routes["routes"]} workflow.graph = json.dumps({"nodes": [{"id": "route-node", "data": node_data}], "edges": []}) - build_workflow_agent_service(sqlite_session)._synchronize_bindings( + build_workflow_agent_service(sqlite_session).synchronize_bindings( draft_workflow=workflow_binding_scope(workflow), account_id="account-1" ) projected = build_workflow_agent_service(sqlite_session).project_draft_bindings_to_graph( 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 diff --git a/api/tests/unit_tests/services/knowledge_fs/knowledge_retrieval_v2/test_knowledge_retrieval_v2_node.py b/api/tests/unit_tests/services/knowledge_fs/knowledge_retrieval_v2/test_knowledge_retrieval_v2_node.py index 2e8863a0b1c3c7..e14b9adf1de9ba 100644 --- a/api/tests/unit_tests/services/knowledge_fs/knowledge_retrieval_v2/test_knowledge_retrieval_v2_node.py +++ b/api/tests/unit_tests/services/knowledge_fs/knowledge_retrieval_v2/test_knowledge_retrieval_v2_node.py @@ -14,7 +14,6 @@ from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom -from core.workflow.node_factory import resolve_workflow_node_class from core.workflow.system_variables import build_system_variables from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from graphon.file import File, FileTransferMethod, FileType @@ -53,6 +52,7 @@ from services.knowledge_fs.product_remote import KnowledgeFSOperationUnavailableError from services.knowledge_fs.query_images import KnowledgeFSWorkflowQueryImageReference from services.knowledge_fs.retrieval_contracts import KnowledgeFSRetrievalTestItemResponse +from services.workflow.execution.adapters.node_factory import resolve_workflow_node_class from tasks import knowledge_fs_failed_retrieval_tasks as failed_retrieval_dependencies from tests.workflow_test_utils import build_test_graph_init_params diff --git a/api/tests/unit_tests/services/knowledge_fs/test_workflow_runtime.py b/api/tests/unit_tests/services/knowledge_fs/test_workflow_runtime.py index f570313f51280d..995f222bdc1a05 100644 --- a/api/tests/unit_tests/services/knowledge_fs/test_workflow_runtime.py +++ b/api/tests/unit_tests/services/knowledge_fs/test_workflow_runtime.py @@ -10,8 +10,6 @@ from sqlalchemy.orm import Session, sessionmaker from core.app.entities.app_invoke_entities import DifyRunContext -from core.workflow.node_factory import DifyNodeFactory -from core.workflow.node_runtime import build_knowledge_fs_node_dependencies from extensions import ext_application_services from graphon.enums import WorkflowNodeExecutionStatus from graphon.runtime import RuntimeState, VariablePool @@ -22,6 +20,8 @@ from services.knowledge_fs.knowledge_retrieval_v2.knowledge_retrieval_v2_node import KnowledgeRetrievalV2Node from services.knowledge_fs.resource import KnowledgeResourceRef from services.knowledge_fs.retrieval_contracts import KnowledgeFSAppBindingPayload, KnowledgeFSRetrievalTestPayload +from services.workflow.execution.adapters.node_factory import DifyNodeFactory +from services.workflow.execution.adapters.node_runtime import build_knowledge_fs_node_dependencies from tests.workflow_test_utils import build_test_graph_init_params diff --git a/api/tests/unit_tests/services/test_workflow_node_variables.py b/api/tests/unit_tests/services/test_workflow_node_variables.py index c6959ca52d449a..2b6cf71e6c85f7 100644 --- a/api/tests/unit_tests/services/test_workflow_node_variables.py +++ b/api/tests/unit_tests/services/test_workflow_node_variables.py @@ -1,10 +1,12 @@ from collections.abc import Mapping, Sequence from typing import Literal +from unittest.mock import Mock import pytest from sqlalchemy import Engine -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker +from core.app.file_access import DatabaseFileAccessController from graphon.entities.graph_config import NodeConfigDictAdapter from graphon.nodes.list_operator.entities import FilterOperator from graphon.runtime import VariablePool @@ -12,11 +14,25 @@ from graphon.variables import StringSegment, VariableBase from models.human_input_entities import HumanInputNodeData from models.workflow import WorkflowDraftVariable +from repositories.workflow.draft_variable_repository import WorkflowDraftVariableRepository +from services.workflow.draft_variable_service import DraftVarLoader from services.workflow.execution.adapters.workflow_entry import WorkflowEntry -from services.workflow_draft_variable_service import DraftVarLoader +from services.workflow.variable_file_gateway import WorkflowVariableFileGateway from services.workflow_node_variables import get_human_input_form_variable_mapping, load_additional_node_variables +def _make_draft_var_loader(sqlite_engine: Engine) -> DraftVarLoader: + sessions = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + return DraftVarLoader( + repository=WorkflowDraftVariableRepository(sessions=sessions), + app_id="app", + tenant_id="tenant", + user_id="user", + load_file=Mock(), + file_inputs=WorkflowVariableFileGateway(sessions, DatabaseFileAccessController()), + ) + + @pytest.mark.parametrize("container_type", [None, "iteration", "loop"]) @pytest.mark.parametrize("saved_root", [False, True]) def test_human_input_body_and_email_keep_nested_editor_selectors( @@ -122,7 +138,7 @@ def test_value_free_list_filters_do_not_require_stale_operands( }, } ) - loader = DraftVarLoader(sqlite_engine, "app", "tenant", "user") + loader = _make_draft_var_loader(sqlite_engine) selectors_loaded: list[list[list[str]]] = [] original_load = loader.load_variables @@ -216,7 +232,7 @@ def test_missing_end_input_loads_saved_draft_value( ) ) sqlite_session.commit() - loader = DraftVarLoader(sqlite_engine, "app", "tenant", "user") + loader = _make_draft_var_loader(sqlite_engine) selectors_loaded: list[list[list[str]]] = [] original_load = loader.load_variables @@ -256,7 +272,7 @@ def test_unavailable_end_output_remains_unset_after_draft_loading( } ) variable_pool = VariablePool() - loader = DraftVarLoader(sqlite_engine, "app", "tenant", "user") + loader = _make_draft_var_loader(sqlite_engine) selectors_loaded: list[list[list[str]]] = [] original_load = loader.load_variables diff --git a/api/tests/unit_tests/services/test_workflow_run_agg.py b/api/tests/unit_tests/services/test_workflow_run_agg.py index 463c90da068312..24cc65ba9dc830 100644 --- a/api/tests/unit_tests/services/test_workflow_run_agg.py +++ b/api/tests/unit_tests/services/test_workflow_run_agg.py @@ -9,9 +9,6 @@ from sqlalchemy.orm import Session, sessionmaker from clients.agent_backend import FakeAgentBackendRunClient, FakeAgentBackendScenario -from core.app.apps.workflow.app_config_manager import WorkflowAppConfig as CoreWorkflowAppConfig -from core.app.apps.workflow.app_queue_manager import WorkflowAppQueueManager as CoreWorkflowAppQueueManager -from core.app.apps.workflow.app_runner import WorkflowAppRunner as CoreWorkflowAppRunner from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, WorkflowAppGenerateEntity from core.app.entities.queue_entities import ( QueueNodeStartedEvent, @@ -401,14 +398,17 @@ def make_workflow_runner( return runner, workflow_repository, node_repository -def _make_legacy_workflow_runner( - sqlite_engine: Engine, *, human_input: bool = False +def _make_disallowed_human_input_runner( + sqlite_engine: Engine, + workflow_runtime: WorkflowExecutionDependencies, + *, + human_input: bool = False, ) -> tuple[ - CoreWorkflowAppRunner, + WorkflowAppRunner, SQLAlchemyWorkflowExecutionRepository, SQLAlchemyWorkflowNodeExecutionRepository, ]: - """Build the live core runner retained until atomic source activation migrates in #43680.""" + """Build a service runner for callers that explicitly disallow Human Input.""" tenant_id, app_id, workflow_id, user_id, run_id = (str(uuid4()) for _ in range(5)) graph: dict[str, list[dict[str, object]]] = { "nodes": [{"id": "start", "data": {"type": "start", **StartNodeData(title="Start").model_dump(mode="json")}}], @@ -447,7 +447,7 @@ def _make_legacy_workflow_runner( entity = WorkflowAppGenerateEntity( user_from=UserFrom.ACCOUNT, task_id=str(uuid4()), - app_config=CoreWorkflowAppConfig( + app_config=WorkflowAppConfig( tenant_id=tenant_id, app_id=app_id, workflow_id=workflow_id, app_mode=AppMode.WORKFLOW ), inputs={}, @@ -459,7 +459,7 @@ def _make_legacy_workflow_runner( ) user = Account(name="Workflow Tester", email=f"{user_id}@example.com") user.id = user_id - queue = CoreWorkflowAppQueueManager(entity.task_id, user_id, entity.invoke_from, AppMode.WORKFLOW) + queue = WorkflowAppQueueManager(entity.task_id, user_id, entity.invoke_from, AppMode.WORKFLOW) workflow_repository = SQLAlchemyWorkflowExecutionRepository( sqlite_engine, tenant_id=tenant_id, @@ -474,13 +474,13 @@ def _make_legacy_workflow_runner( app_id=app_id, triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, ) - runner = CoreWorkflowAppRunner( + runner = WorkflowAppRunner( + runtime=workflow_runtime, application_generate_entity=entity, queue_manager=queue, variable_loader=DUMMY_VARIABLE_LOADER, - workflow=workflow, + workflow=workflow_snapshot(workflow), system_user_id=user_id, - execution_driver=WorkflowRunAgg.run, ) return runner, workflow_repository, node_repository @@ -662,12 +662,14 @@ def retire(_store: object, **kwargs: object) -> list[str]: @staticmethod def test_late_form_submission_is_refreshed_before_the_real_queue_closes( - sqlite_session: Session, workflow_runtime: WorkflowExecutionDependencies + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + workflow_runtime: WorkflowExecutionDependencies, ) -> None: from core.app.entities.queue_entities import QueueHumanInputFormFilledEvent - from core.repositories.human_input_repository import HumanInputFormSubmissionRepository from enums.human_input import HumanInputFormStatus from graphon.engine_events import NodeRunStartedEvent + from repositories.human_input.form_repository import HumanInputFormSubmissionRepository from tests.unit_tests.core.app.apps.test_workflow_app_runner import ( _build_runner, _make_paused_workflow, @@ -683,7 +685,7 @@ def test_late_form_submission_is_refreshed_before_the_real_queue_closes( for event in _resume_events(runner, entry): runner.handle_event(entry, event) if isinstance(event, NodeRunStartedEvent): - HumanInputFormSubmissionRepository().mark_submitted( + HumanInputFormSubmissionRepository(sessions=sqlite_session_factory).mark_submitted( form_id=late.id, recipient_id=None, selected_action_id="approve", @@ -700,11 +702,13 @@ def test_late_form_submission_is_refreshed_before_the_real_queue_closes( @staticmethod @pytest.mark.usefixtures("_local_storage") def test_durable_pause_resumes_with_one_form_completion_and_continuing_indexes( - sqlite_engine: Engine, workflow_runtime: WorkflowExecutionDependencies + sqlite_engine: Engine, + sqlite_session_factory: sessionmaker[Session], + workflow_runtime: WorkflowExecutionDependencies, ) -> None: from core.app.entities.queue_entities import QueueHumanInputFormFilledEvent, QueueWorkflowStartedEvent - from core.repositories.human_input_repository import HumanInputFormSubmissionRepository from models.human_input import HumanInputForm + from repositories.human_input.form_repository import HumanInputFormSubmissionRepository original, workflow_repository, node_repository = make_workflow_runner( sqlite_engine, workflow_runtime, human_input=True @@ -720,7 +724,7 @@ def test_durable_pause_resumes_with_one_form_completion_and_continuing_indexes( assert form is not None form_id = form.id snapshot = WorkflowResumptionContext.loads(storage.load(pause.state_object_key).decode()) - HumanInputFormSubmissionRepository().mark_submitted( + HumanInputFormSubmissionRepository(sessions=sqlite_session_factory).mark_submitted( form_id=form_id, recipient_id=None, selected_action_id="approve", @@ -991,6 +995,7 @@ def test_active_execution_snapshot_failure_is_not_published_as_a_pause( def test_elapsed_time_accumulates_across_pauses_and_matches_delayed_stream_responses( monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, + sqlite_session_factory: sessionmaker[Session], workflow_runtime: WorkflowExecutionDependencies, terminal_event: GraphRunSucceededEvent | GraphRunPartialSucceededEvent @@ -999,11 +1004,11 @@ def test_elapsed_time_accumulates_across_pauses_and_matches_delayed_stream_respo status: WorkflowExecutionStatus, ) -> None: from core.app.entities.task_entities import WorkflowPauseStreamResponse - from core.repositories.human_input_repository import HumanInputFormSubmissionRepository from core.workflow.system_variables import build_system_variables from enums.human_input import HumanInputFormStatus from graphon.entities import WorkflowStartReason from models.human_input import HumanInputForm + from repositories.human_input.form_repository import HumanInputFormSubmissionRepository from services.workflow.execution.adapters.response_converter import WorkflowResponseConverter clock = [100.0] @@ -1098,7 +1103,7 @@ def test_elapsed_time_accumulates_across_pauses_and_matches_delayed_stream_respo entity = snapshot.get_generate_entity() assert isinstance(entity, WorkflowAppGenerateEntity) assert entity.workflow_execution_timing.elapsed_time == snapshot.elapsed_time == total - HumanInputFormSubmissionRepository().mark_submitted( + HumanInputFormSubmissionRepository(sessions=sqlite_session_factory).mark_submitted( form_id=form_id, recipient_id=None, selected_action_id="approve", @@ -1200,10 +1205,14 @@ def persist(aggregate: WorkflowRunAgg, event: GraphRunPausedEvent) -> None: assert all(form.status == HumanInputFormStatus.EXPIRED for form in forms) @staticmethod - def test_legacy_caller_rejects_human_input_before_creating_run_or_form(sqlite_engine: Engine) -> None: + def test_legacy_caller_rejects_human_input_before_creating_run_or_form( + sqlite_engine: Engine, workflow_runtime: WorkflowExecutionDependencies + ) -> None: from models.human_input import HumanInputForm - runner, workflow_repository, node_repository = _make_legacy_workflow_runner(sqlite_engine, human_input=True) + runner, workflow_repository, node_repository = _make_disallowed_human_input_runner( + sqlite_engine, workflow_runtime, human_input=True + ) runner.application_generate_entity.allow_human_input = False with pytest.raises(ValueError, match="Human Input nodes are not supported"): WorkflowRunAgg.run(runner, None, workflow_repository, node_repository) diff --git a/api/tests/unit_tests/services/tracing/test_runtime_bridge.py b/api/tests/unit_tests/services/tracing/test_runtime_bridge.py index 3776be89018bdf..f4b310e19e208f 100644 --- a/api/tests/unit_tests/services/tracing/test_runtime_bridge.py +++ b/api/tests/unit_tests/services/tracing/test_runtime_bridge.py @@ -16,7 +16,6 @@ from sqlalchemy.orm import Session, sessionmaker from core.app.app_config.entities import WorkflowUIBasedAppConfig -from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeBuildContext from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom, WorkflowAppGenerateEntity from core.ops.base_trace_instance import bind_application_tracing_hooks from core.ops.entities.trace_entity import ( @@ -30,7 +29,6 @@ from core.ops.ops_trace_manager import OpsTraceManager from core.ops.unified_trace.trace_builder import CanonicalTraceBuilder from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository -from core.workflow.nodes.agent_v2.runtime_request_builder import WorkflowAgentRuntimeBuildContext from extensions.ext_database import db from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus from graphon.runtime import VariablePool @@ -57,10 +55,12 @@ ) from models.workflow import WorkflowType as _application_trace_handoff_WorkflowType from repositories.application_trace_repository import ApplicationTraceRepository +from services.app.generation.adapters.agent_request_builder import AgentAppRuntimeBuildContext from services.tracing import runtime_bridge from services.tracing.normalization import remap_outer_trace from services.tracing.provider_dispatch import export_trace from services.tracing.service import ApplicationTracingService +from services.workflow.execution.adapters.agent_v2.runtime_request_builder import WorkflowAgentRuntimeBuildContext from tasks import ops_trace_task from tests.helpers.unit.config_override import apply_config_overrides from tests.helpers.unit.model_factories import make_account, make_app, make_conversation, make_workflow