diff --git a/.gitignore b/.gitignore index 207e2600e7..5b434ee4ec 100644 --- a/.gitignore +++ b/.gitignore @@ -257,5 +257,5 @@ scripts/stress-test/reports/ # Code Agent Folder .qoder/* -.context/* +.context/ .eslintcache diff --git a/api/clients/agent_backend/__init__.py b/api/clients/agent_backend/__init__.py index 2e3777f61b..b2e0a30944 100644 --- a/api/clients/agent_backend/__init__.py +++ b/api/clients/agent_backend/__init__.py @@ -30,7 +30,7 @@ from clients.agent_backend.factory import create_agent_backend_run_client from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario from clients.agent_backend.request_builder import ( AGENT_SOUL_PROMPT_LAYER_ID, - DIFY_PLUGIN_CONTEXT_LAYER_ID, + DIFY_EXECUTION_CONTEXT_LAYER_ID, WORKFLOW_NODE_JOB_PROMPT_LAYER_ID, WORKFLOW_USER_PROMPT_LAYER_ID, AgentBackendModelConfig, @@ -42,7 +42,7 @@ from clients.agent_backend.request_builder import ( __all__ = [ "AGENT_SOUL_PROMPT_LAYER_ID", - "DIFY_PLUGIN_CONTEXT_LAYER_ID", + "DIFY_EXECUTION_CONTEXT_LAYER_ID", "WORKFLOW_NODE_JOB_PROMPT_LAYER_ID", "WORKFLOW_USER_PROMPT_LAYER_ID", "AgentBackendError", diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index 392eee641b..c1e7bb4de8 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -4,7 +4,9 @@ This module is intentionally an adapter, not a wire DTO package. The emitted object is always ``dify_agent.protocol.CreateRunRequest`` so the Agent backend protocol has a single owner. API-only context such as Agent Soul vs workflow job prompt is preserved in layer names and metadata until the dedicated product -schemas land in later phases. +schemas land in later phases. Dify-owned execution identifiers are emitted as an +explicit ``dify.execution_context`` layer so the run request stays fully +composition-driven. """ from __future__ import annotations @@ -15,18 +17,19 @@ from agenton.compositor import CompositorSessionSnapshot from agenton.layers import ExitIntent from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig from dify_agent.layers.dify_plugin import ( - DIFY_PLUGIN_LAYER_TYPE_ID, DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DifyPluginCredentialValue, - DifyPluginLayerConfig, DifyPluginLLMLayerConfig, ) +from dify_agent.layers.execution_context import ( + DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + DifyExecutionContextLayerConfig, +) from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig from dify_agent.protocol import ( DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID, CreateRunRequest, - ExecutionContext, LayerExitSignals, RunComposition, RunLayerSpec, @@ -37,17 +40,15 @@ from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator AGENT_SOUL_PROMPT_LAYER_ID = "agent_soul_prompt" WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt" WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt" -DIFY_PLUGIN_CONTEXT_LAYER_ID = "plugin" +DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context" class AgentBackendModelConfig(BaseModel): """API-side model/plugin selection before it is converted to Dify Agent layers.""" - tenant_id: str plugin_id: str model_provider: str model: str - user_id: str | None = None credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict) model_settings: dict[str, JsonValue] = Field(default_factory=dict) @@ -73,7 +74,7 @@ class AgentBackendWorkflowNodeRunInput(BaseModel): """Inputs needed to build the first workflow-node-oriented Agent backend run request.""" model: AgentBackendModelConfig - execution_context: ExecutionContext + execution_context: DifyExecutionContextLayerConfig workflow_node_job_prompt: str user_prompt: str agent_soul_prompt: str | None = None @@ -125,21 +126,18 @@ class AgentBackendRunRequestBuilder: config=PromptLayerConfig(user=run_input.user_prompt), ), RunLayerSpec( - name=DIFY_PLUGIN_CONTEXT_LAYER_ID, - type=DIFY_PLUGIN_LAYER_TYPE_ID, + name=DIFY_EXECUTION_CONTEXT_LAYER_ID, + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, metadata=run_input.metadata, - config=DifyPluginLayerConfig( - tenant_id=run_input.model.tenant_id, - plugin_id=run_input.model.plugin_id, - user_id=run_input.model.user_id, - ), + config=run_input.execution_context, ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID, - deps={"plugin": DIFY_PLUGIN_CONTEXT_LAYER_ID}, + deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, metadata=run_input.metadata, config=DifyPluginLLMLayerConfig( + plugin_id=run_input.model.plugin_id, model_provider=run_input.model.model_provider, model=run_input.model.model, credentials=run_input.model.credentials, @@ -165,7 +163,6 @@ class AgentBackendRunRequestBuilder: return CreateRunRequest( composition=RunComposition(layers=layers), - execution_context=run_input.execution_context, purpose=run_input.purpose, idempotency_key=run_input.idempotency_key, metadata=run_input.metadata, diff --git a/api/core/agent/base_agent_runner.py b/api/core/agent/base_agent_runner.py index cba4659483..694d633148 100644 --- a/api/core/agent/base_agent_runner.py +++ b/api/core/agent/base_agent_runner.py @@ -22,9 +22,6 @@ from core.memory.token_buffer_memory import TokenBufferMemory from core.model_manager import ModelInstance from core.prompt.utils.extract_thread_messages import extract_thread_messages from core.tools.__base.tool import Tool -from core.tools.entities.tool_entities import ( - ToolParameter, -) from core.tools.tool_manager import ToolManager from core.tools.utils.dataset_retriever_tool import DatasetRetrieverTool from extensions.ext_database import db @@ -150,44 +147,9 @@ class BaseAgentRunner(AppRunner): message_tool = PromptMessageTool( name=tool.tool_name, description=tool_entity.entity.description.llm, - parameters={ - "type": "object", - "properties": {}, - "required": [], - }, + parameters=tool_entity.get_llm_parameters_json_schema(), ) - parameters = tool_entity.get_merged_runtime_parameters() - for parameter in parameters: - if parameter.form != ToolParameter.ToolParameterForm.LLM: - continue - - parameter_type = parameter.type.as_normal_type() - if parameter.type in { - ToolParameter.ToolParameterType.SYSTEM_FILES, - ToolParameter.ToolParameterType.FILE, - ToolParameter.ToolParameterType.FILES, - }: - continue - enum = [] - if parameter.type == ToolParameter.ToolParameterType.SELECT: - enum = [option.value for option in parameter.options] if parameter.options else [] - - message_tool.parameters["properties"][parameter.name] = ( - { - "type": parameter_type, - "description": parameter.llm_description or "", - } - if parameter.input_schema is None - else parameter.input_schema - ) - - if len(enum) > 0: - message_tool.parameters["properties"][parameter.name]["enum"] = enum - - if parameter.required: - message_tool.parameters["required"].append(parameter.name) - return message_tool, tool_entity def _convert_dataset_retriever_tool_to_prompt_message_tool(self, tool: DatasetRetrieverTool) -> PromptMessageTool: @@ -252,40 +214,7 @@ class BaseAgentRunner(AppRunner): """ update prompt message tool """ - # try to get tool runtime parameters - tool_runtime_parameters = tool.get_runtime_parameters() - - for parameter in tool_runtime_parameters: - if parameter.form != ToolParameter.ToolParameterForm.LLM: - continue - - parameter_type = parameter.type.as_normal_type() - if parameter.type in { - ToolParameter.ToolParameterType.SYSTEM_FILES, - ToolParameter.ToolParameterType.FILE, - ToolParameter.ToolParameterType.FILES, - }: - continue - enum = [] - if parameter.type == ToolParameter.ToolParameterType.SELECT: - enum = [option.value for option in parameter.options] if parameter.options else [] - - prompt_tool.parameters["properties"][parameter.name] = ( - { - "type": parameter_type, - "description": parameter.llm_description or "", - } - if parameter.input_schema is None - else parameter.input_schema - ) - - if len(enum) > 0: - prompt_tool.parameters["properties"][parameter.name]["enum"] = enum - - if parameter.required: - if parameter.name not in prompt_tool.parameters["required"]: - prompt_tool.parameters["required"].append(parameter.name) - + prompt_tool.parameters = tool.get_llm_parameters_json_schema() return prompt_tool def create_agent_thought( diff --git a/api/core/tools/__base/tool.py b/api/core/tools/__base/tool.py index ab0f73a9a2..4d784b5f23 100644 --- a/api/core/tools/__base/tool.py +++ b/api/core/tools/__base/tool.py @@ -126,34 +126,89 @@ class Tool(ABC): message_id: str | None = None, ) -> list[ToolParameter]: """ - get merged runtime parameters + Get the effective parameter declarations for this tool. + + Runtime parameters override declared parameters by name and append new + parameters, but the returned list is always detached from the tool's + cached declarations so callers can safely mutate it while building + downstream schemas. :return: merged runtime parameters """ - parameters = self.entity.parameters - parameters = parameters.copy() - user_parameters = self.get_runtime_parameters() or [] - user_parameters = user_parameters.copy() + parameters = [deepcopy(parameter) for parameter in self.entity.parameters or []] + user_parameters = [ + deepcopy(parameter) + for parameter in self.get_runtime_parameters( + conversation_id=conversation_id, + app_id=app_id, + message_id=message_id, + ) + or [] + ] + + parameter_indexes = {parameter.name: index for index, parameter in enumerate(parameters)} - # override parameters for parameter in user_parameters: - # check if parameter in tool parameters - for tool_parameter in parameters: - if tool_parameter.name == parameter.name: - # override parameter - tool_parameter.type = parameter.type - tool_parameter.form = parameter.form - tool_parameter.required = parameter.required - tool_parameter.default = parameter.default - tool_parameter.options = parameter.options - tool_parameter.llm_description = parameter.llm_description - break - else: - # add new parameter + existing_index = parameter_indexes.get(parameter.name) + if existing_index is None: + parameter_indexes[parameter.name] = len(parameters) parameters.append(parameter) + continue + parameters[existing_index] = parameter return parameters + def get_llm_parameters_json_schema( + self, + conversation_id: str | None = None, + app_id: str | None = None, + message_id: str | None = None, + ) -> dict[str, Any]: + """Build the model-visible JSON schema from effective tool parameters. + + Hidden/manual parameters stay available for invocation preparation on the + API side, but are intentionally omitted from the LLM-facing schema. + """ + schema: dict[str, Any] = { + "type": "object", + "properties": {}, + "required": [], + } + + for parameter in self.get_merged_runtime_parameters( + conversation_id=conversation_id, + app_id=app_id, + message_id=message_id, + ): + if parameter.form != ToolParameter.ToolParameterForm.LLM: + continue + + if parameter.type in { + ToolParameter.ToolParameterType.SYSTEM_FILES, + ToolParameter.ToolParameterType.FILE, + ToolParameter.ToolParameterType.FILES, + }: + continue + + parameter_schema: dict[str, Any] = ( + { + "type": parameter.type.as_normal_type(), + "description": parameter.llm_description or "", + } + if parameter.input_schema is None + else deepcopy(parameter.input_schema) + ) + parameter_schema.setdefault("description", parameter.llm_description or "") + + if parameter.type == ToolParameter.ToolParameterType.SELECT and parameter.options: + parameter_schema["enum"] = [option.value for option in parameter.options] + + schema["properties"][parameter.name] = parameter_schema + if parameter.required: + schema["required"].append(parameter.name) + + return schema + def create_image_message( self, image: str, diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index 431f658e33..effd6b9d8a 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -4,7 +4,8 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import Any, Literal, Protocol, cast -from dify_agent.protocol import CreateRunRequest, ExecutionContext +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig +from dify_agent.protocol import CreateRunRequest from clients.agent_backend import ( AgentBackendModelConfig, @@ -105,16 +106,20 @@ class WorkflowAgentRuntimeRequestBuilder: request = self._request_builder.build_for_workflow_node( AgentBackendWorkflowNodeRunInput( model=AgentBackendModelConfig( - tenant_id=context.dify_context.tenant_id, plugin_id=agent_soul.model.plugin_id, model_provider=agent_soul.model.model_provider, model=agent_soul.model.model, - user_id=context.dify_context.user_id, credentials=self._normalize_credentials(credentials), model_settings=cast(dict[str, Any], agent_soul.model.model_settings), ), - execution_context=ExecutionContext( + # The execution-context layer is now the only public protocol + # carrier for Dify tenant/user/run identifiers. ``user_id`` must + # be forwarded here because downstream plugin-daemon provider and + # tool clients read it 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, app_id=context.dify_context.app_id, workflow_id=context.workflow_id, workflow_run_id=context.workflow_run_id, diff --git a/api/tests/unit_tests/clients/agent_backend/test_client.py b/api/tests/unit_tests/clients/agent_backend/test_client.py index 7e3be42551..407372d29d 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_client.py +++ b/api/tests/unit_tests/clients/agent_backend/test_client.py @@ -2,12 +2,12 @@ from collections.abc import Iterator import pytest from dify_agent.client import DifyAgentHTTPError, DifyAgentStreamError, DifyAgentTimeoutError, DifyAgentValidationError +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.protocol import ( CancelRunRequest, CancelRunResponse, CreateRunRequest, CreateRunResponse, - ExecutionContext, RunEvent, RunStartedEvent, RunStatusResponse, @@ -29,12 +29,11 @@ def _request(): return AgentBackendRunRequestBuilder().build_for_workflow_node( AgentBackendWorkflowNodeRunInput( model=AgentBackendModelConfig( - tenant_id="tenant-1", plugin_id="langgenius/openai", model_provider="openai", model="gpt-test", ), - execution_context=ExecutionContext(tenant_id="tenant-1", invoke_from="workflow_run"), + execution_context=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), workflow_node_job_prompt="Do the task.", user_prompt="hello", ) diff --git a/api/tests/unit_tests/clients/agent_backend/test_fake_client.py b/api/tests/unit_tests/clients/agent_backend/test_fake_client.py index 80b398988a..087cffef81 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_fake_client.py +++ b/api/tests/unit_tests/clients/agent_backend/test_fake_client.py @@ -1,4 +1,4 @@ -from dify_agent.protocol import ExecutionContext +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from clients.agent_backend import ( AgentBackendModelConfig, @@ -13,12 +13,11 @@ def _request(): return AgentBackendRunRequestBuilder().build_for_workflow_node( AgentBackendWorkflowNodeRunInput( model=AgentBackendModelConfig( - tenant_id="tenant-1", plugin_id="langgenius/openai", model_provider="openai", model="gpt-test", ), - execution_context=ExecutionContext(tenant_id="tenant-1", invoke_from="workflow_run"), + execution_context=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), workflow_node_job_prompt="Do the task.", user_prompt="hello", ) diff --git a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py index 44c795d70d..6f598e1901 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py +++ b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py @@ -1,18 +1,19 @@ import pytest from agenton.layers import ExitIntent from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID -from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LAYER_TYPE_ID, DIFY_PLUGIN_LLM_LAYER_TYPE_ID +from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID +from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID from dify_agent.protocol import ( DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID, CreateRunRequest, - ExecutionContext, ) from pydantic import ValidationError from clients.agent_backend import ( AGENT_SOUL_PROMPT_LAYER_ID, + DIFY_EXECUTION_CONTEXT_LAYER_ID, WORKFLOW_NODE_JOB_PROMPT_LAYER_ID, WORKFLOW_USER_PROMPT_LAYER_ID, AgentBackendModelConfig, @@ -26,15 +27,14 @@ from clients.agent_backend import ( def _run_input() -> AgentBackendWorkflowNodeRunInput: return AgentBackendWorkflowNodeRunInput( model=AgentBackendModelConfig( - tenant_id="tenant-1", plugin_id="langgenius/openai", - user_id="user-1", model_provider="openai", model="gpt-test", credentials={"api_key": "secret-key"}, ), - execution_context=ExecutionContext( + execution_context=DifyExecutionContextLayerConfig( tenant_id="tenant-1", + user_id="user-1", workflow_id="workflow-1", workflow_run_id="workflow-run-1", node_id="node-1", @@ -64,13 +64,11 @@ def test_request_builder_outputs_dify_agent_create_run_request(): AGENT_SOUL_PROMPT_LAYER_ID, WORKFLOW_NODE_JOB_PROMPT_LAYER_ID, WORKFLOW_USER_PROMPT_LAYER_ID, - "plugin", + DIFY_EXECUTION_CONTEXT_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID, ] assert request.on_exit.default is ExitIntent.DELETE - assert request.execution_context is not None - assert request.execution_context.node_execution_id == "node-execution-1" assert request.idempotency_key == "workflow-run-1:node-execution-1" assert request.metadata == {"workflow_id": "workflow-1", "node_id": "node-1"} @@ -94,9 +92,11 @@ def test_request_builder_sets_model_and_output_layer_contract_ids(): request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input()) layers = {layer.name: layer for layer in request.composition.layers} - assert layers["plugin"].type == DIFY_PLUGIN_LAYER_TYPE_ID + assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID].type == DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID + assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID].config.user_id == "user-1" assert layers[DIFY_AGENT_MODEL_LAYER_ID].type == DIFY_PLUGIN_LLM_LAYER_TYPE_ID - assert layers[DIFY_AGENT_MODEL_LAYER_ID].deps == {"plugin": "plugin"} + assert layers[DIFY_AGENT_MODEL_LAYER_ID].config.plugin_id == "langgenius/openai" + assert layers[DIFY_AGENT_MODEL_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} assert layers[DIFY_AGENT_OUTPUT_LAYER_ID].type == DIFY_OUTPUT_LAYER_TYPE_ID @@ -113,12 +113,11 @@ def test_request_builder_rejects_blank_prompts(): with pytest.raises(ValidationError): AgentBackendWorkflowNodeRunInput( model=AgentBackendModelConfig( - tenant_id="tenant-1", plugin_id="langgenius/openai", model_provider="openai", model="gpt-test", ), - execution_context=ExecutionContext(tenant_id="tenant-1", invoke_from="workflow_run"), + execution_context=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), workflow_node_job_prompt=" ", user_prompt="hello", ) diff --git a/api/tests/unit_tests/core/agent/test_base_agent_runner.py b/api/tests/unit_tests/core/agent/test_base_agent_runner.py index d5fb853ee3..f5e4b09993 100644 --- a/api/tests/unit_tests/core/agent/test_base_agent_runner.py +++ b/api/tests/unit_tests/core/agent/test_base_agent_runner.py @@ -61,79 +61,20 @@ class TestRepack: class TestUpdatePromptTool: - def build_param(self, mocker: MockerFixture, **kwargs): - p = mocker.MagicMock() - p.form = kwargs.get("form") - - mock_type = mocker.MagicMock() - mock_type.as_normal_type.return_value = "string" - p.type = mock_type - - p.name = kwargs.get("name", "p1") - p.llm_description = "desc" - p.input_schema = kwargs.get("input_schema") - p.options = kwargs.get("options") - p.required = kwargs.get("required", False) - return p - - def test_skip_non_llm(self, runner, mocker: MockerFixture): + def test_replaces_prompt_tool_parameters_with_tool_schema(self, runner, mocker: MockerFixture): tool = mocker.MagicMock() - param = self.build_param(mocker, form="NOT_LLM") - tool.get_runtime_parameters.return_value = [param] + schema = { + "type": "object", + "properties": {"p1": {"type": "string", "description": "desc"}}, + "required": ["p1"], + } + tool.get_llm_parameters_json_schema.return_value = schema prompt_tool = mocker.MagicMock() prompt_tool.parameters = {"properties": {}, "required": []} result = runner.update_prompt_message_tool(tool, prompt_tool) - assert result.parameters["properties"] == {} - - def test_enum_and_required(self, runner, mocker: MockerFixture): - option = mocker.MagicMock(value="opt1") - param = self.build_param( - mocker, - form=module.ToolParameter.ToolParameterForm.LLM, - options=[option], - required=True, - ) - - tool = mocker.MagicMock() - tool.get_runtime_parameters.return_value = [param] - - prompt_tool = mocker.MagicMock() - prompt_tool.parameters = {"properties": {}, "required": []} - - result = runner.update_prompt_message_tool(tool, prompt_tool) - assert "p1" in result.parameters["required"] - - def test_skip_file_type_param(self, runner, mocker: MockerFixture): - tool = mocker.MagicMock() - param = self.build_param(mocker, form=module.ToolParameter.ToolParameterForm.LLM) - param.type = module.ToolParameter.ToolParameterType.FILE - tool.get_runtime_parameters.return_value = [param] - - prompt_tool = mocker.MagicMock() - prompt_tool.parameters = {"properties": {}, "required": []} - - result = runner.update_prompt_message_tool(tool, prompt_tool) - assert result.parameters["properties"] == {} - - def test_duplicate_required_not_duplicated(self, runner, mocker: MockerFixture): - tool = mocker.MagicMock() - - param = self.build_param( - mocker, - form=module.ToolParameter.ToolParameterForm.LLM, - required=True, - ) - - tool.get_runtime_parameters.return_value = [param] - - prompt_tool = mocker.MagicMock() - prompt_tool.parameters = {"properties": {}, "required": ["p1"]} - - result = runner.update_prompt_message_tool(tool, prompt_tool) - - assert result.parameters["required"].count("p1") == 1 + assert result.parameters == schema # ========================================================== @@ -383,57 +324,21 @@ class TestConvertToolToPromptMessageTool: def test_basic_conversion(self, runner, mocker: MockerFixture): tool = mocker.MagicMock(tool_name="tool1") - runtime_param = mocker.MagicMock() - runtime_param.form = module.ToolParameter.ToolParameterForm.LLM - runtime_param.name = "param1" - runtime_param.llm_description = "desc" - runtime_param.required = True - runtime_param.input_schema = None - runtime_param.options = None - - mock_type = mocker.MagicMock() - mock_type.as_normal_type.return_value = "string" - runtime_param.type = mock_type - tool_entity = mocker.MagicMock() tool_entity.entity.description.llm = "desc" - tool_entity.get_merged_runtime_parameters.return_value = [runtime_param] + schema = { + "type": "object", + "properties": {"param1": {"type": "string", "description": "desc"}}, + "required": ["param1"], + } + tool_entity.get_llm_parameters_json_schema.return_value = schema mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=tool_entity) mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw)) prompt_tool, entity = runner._convert_tool_to_prompt_message_tool(tool) assert entity == tool_entity - - def test_full_conversion_multiple_params(self, runner, mocker: MockerFixture): - tool = mocker.MagicMock(tool_name="tool1") - - # LLM param with input_schema override - param1 = mocker.MagicMock() - param1.form = module.ToolParameter.ToolParameterForm.LLM - param1.name = "p1" - param1.llm_description = "desc" - param1.required = True - param1.input_schema = {"type": "integer"} - param1.options = None - param1.type = mocker.MagicMock() - - # SYSTEM_FILES param should be skipped - param2 = mocker.MagicMock() - param2.form = module.ToolParameter.ToolParameterForm.LLM - param2.name = "file_param" - param2.type = module.ToolParameter.ToolParameterType.SYSTEM_FILES - - tool_entity = mocker.MagicMock() - tool_entity.entity.description.llm = "desc" - tool_entity.get_merged_runtime_parameters.return_value = [param1, param2] - - mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=tool_entity) - mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw)) - - prompt_tool, entity = runner._convert_tool_to_prompt_message_tool(tool) - - assert entity == tool_entity + assert prompt_tool.parameters == schema # ========================================================== @@ -465,29 +370,6 @@ class TestInitPromptToolsExtended: class TestAdditionalCoverage: - def test_update_prompt_with_input_schema(self, runner, mocker: MockerFixture): - tool = mocker.MagicMock() - - param = mocker.MagicMock() - param.form = module.ToolParameter.ToolParameterForm.LLM - param.name = "p1" - param.required = False - param.llm_description = "desc" - param.options = None - param.input_schema = {"type": "number"} - - mock_type = mocker.MagicMock() - mock_type.as_normal_type.return_value = "string" - param.type = mock_type - - tool.get_runtime_parameters.return_value = [param] - - prompt_tool = mocker.MagicMock() - prompt_tool.parameters = {"properties": {}, "required": []} - - result = runner.update_prompt_message_tool(tool, prompt_tool) - assert result.parameters["properties"]["p1"]["type"] == "number" - def test_save_agent_thought_existing_labels(self, runner, mock_db_session, mocker: MockerFixture): agent = mocker.MagicMock() agent.tool = "tool1" @@ -571,33 +453,6 @@ class TestAdditionalCoverage: result = runner.organize_agent_history([]) assert isinstance(result, list) - # ================= Additional Surgical Coverage ================= - - def test_convert_tool_select_enum_branch(self, runner, mocker: MockerFixture): - tool = mocker.MagicMock(tool_name="tool1") - - param = mocker.MagicMock() - param.form = module.ToolParameter.ToolParameterForm.LLM - param.name = "select_param" - param.required = True - param.llm_description = "desc" - param.input_schema = None - - option1 = mocker.MagicMock(value="A") - option2 = mocker.MagicMock(value="B") - param.options = [option1, option2] - param.type = module.ToolParameter.ToolParameterType.SELECT - - tool_entity = mocker.MagicMock() - tool_entity.entity.description.llm = "desc" - tool_entity.get_merged_runtime_parameters.return_value = [param] - - mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=tool_entity) - mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw)) - - prompt_tool, _ = runner._convert_tool_to_prompt_message_tool(tool) - assert prompt_tool is not None - class TestConvertDatasetRetrieverTool: def test_required_param_added(self, runner, mocker: MockerFixture): @@ -663,24 +518,6 @@ class TestBaseAgentRunnerInit: class TestBaseAgentRunnerCoverage: - def test_convert_tool_skips_non_llm_param(self, runner, mocker: MockerFixture): - tool = mocker.MagicMock(tool_name="tool1") - - param = mocker.MagicMock() - param.form = "NOT_LLM" - param.type = mocker.MagicMock() - - tool_entity = mocker.MagicMock() - tool_entity.entity.description.llm = "desc" - tool_entity.get_merged_runtime_parameters.return_value = [param] - - mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=tool_entity) - mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw)) - - prompt_tool, _ = runner._convert_tool_to_prompt_message_tool(tool) - - assert prompt_tool.parameters["properties"] == {} - def test_init_prompt_tools_adds_dataset_tools(self, runner, mocker: MockerFixture): dataset_tool = mocker.MagicMock() dataset_tool.entity.identity.name = "ds" @@ -693,30 +530,6 @@ class TestBaseAgentRunnerCoverage: assert tools["ds"] == dataset_tool assert len(prompt_tools) == 1 - def test_update_prompt_message_tool_select_enum(self, runner, mocker: MockerFixture): - tool = mocker.MagicMock() - - option1 = mocker.MagicMock(value="A") - option2 = mocker.MagicMock(value="B") - - param = mocker.MagicMock() - param.form = module.ToolParameter.ToolParameterForm.LLM - param.name = "select_param" - param.required = False - param.llm_description = "desc" - param.input_schema = None - param.options = [option1, option2] - param.type = module.ToolParameter.ToolParameterType.SELECT - - tool.get_runtime_parameters.return_value = [param] - - prompt_tool = mocker.MagicMock() - prompt_tool.parameters = {"properties": {}, "required": []} - - result = runner.update_prompt_message_tool(tool, prompt_tool) - - assert result.parameters["properties"]["select_param"]["enum"] == ["A", "B"] - def test_save_agent_thought_json_dumps_fallbacks(self, runner, mock_db_session, mocker: MockerFixture): agent = mocker.MagicMock() agent.tool = "tool1" diff --git a/api/tests/unit_tests/core/tools/test_base_tool.py b/api/tests/unit_tests/core/tools/test_base_tool.py index 23d3e77c1d..9486144e98 100644 --- a/api/tests/unit_tests/core/tools/test_base_tool.py +++ b/api/tests/unit_tests/core/tools/test_base_tool.py @@ -8,7 +8,13 @@ from core.app.entities.app_invoke_entities import InvokeFrom from core.tools.__base.tool import Tool from core.tools.__base.tool_runtime import ToolRuntime from core.tools.entities.common_entities import I18nObject -from core.tools.entities.tool_entities import ToolEntity, ToolIdentity, ToolInvokeMessage, ToolProviderType +from core.tools.entities.tool_entities import ( + ToolEntity, + ToolIdentity, + ToolInvokeMessage, + ToolParameter, + ToolProviderType, +) class DummyCastType: @@ -25,6 +31,7 @@ class DummyParameter: default: Any = None options: list[Any] | None = None llm_description: str | None = None + input_schema: dict[str, Any] | None = None class DummyTool(Tool): @@ -149,13 +156,27 @@ def test_fork_tool_runtime_returns_new_tool_with_copied_entity(): def test_get_runtime_parameters_and_merge_runtime_parameters(): tool = _build_tool() - original = DummyParameter(name="temperature", type=DummyCastType(), form="schema", required=True, default="0.7") + original = DummyParameter( + name="temperature", + type=DummyCastType(), + form="schema", + required=True, + default="0.7", + input_schema={"type": "string"}, + ) tool.entity.parameters = cast(Any, [original]) default_runtime_parameters = tool.get_runtime_parameters() assert default_runtime_parameters == [original] - override = DummyParameter(name="temperature", type=DummyCastType(), form="llm", required=False, default="0.5") + override = DummyParameter( + name="temperature", + type=DummyCastType(), + form="llm", + required=False, + default="0.5", + input_schema={"type": "object"}, + ) appended = DummyParameter(name="new_param", type=DummyCastType(), form="form", required=False, default="x") tool.runtime_parameter_overrides = [override, appended] @@ -165,7 +186,93 @@ def test_get_runtime_parameters_and_merge_runtime_parameters(): assert merged[0].form == "llm" assert merged[0].required is False assert merged[0].default == "0.5" + assert merged[0].input_schema == {"type": "object"} assert merged[1].name == "new_param" + assert merged[0] is not original + assert merged[1] is not appended + assert original.form == "schema" + assert original.required is True + assert original.default == "0.7" + assert original.input_schema == {"type": "string"} + + +def test_get_llm_parameters_json_schema_uses_effective_runtime_parameters(): + tool = _build_tool() + query_parameter = ToolParameter.get_simple_instance( + name="query", + llm_description="Declared query", + typ=ToolParameter.ToolParameterType.STRING, + required=True, + ) + region_parameter = ToolParameter.get_simple_instance( + name="region", + llm_description="Search region", + typ=ToolParameter.ToolParameterType.SELECT, + required=False, + options=["global", "cn"], + ) + hidden_parameter = ToolParameter.get_simple_instance( + name="api_key", + llm_description="Hidden api key", + typ=ToolParameter.ToolParameterType.STRING, + required=True, + ) + hidden_parameter.form = ToolParameter.ToolParameterForm.FORM + file_parameter = ToolParameter.get_simple_instance( + name="attachment", + llm_description="Attachment", + typ=ToolParameter.ToolParameterType.FILE, + required=False, + ) + payload_parameter = ToolParameter( + name="payload", + label=I18nObject(en_US="payload", zh_Hans="payload"), + placeholder=None, + human_description=I18nObject(en_US="payload", zh_Hans="payload"), + type=ToolParameter.ToolParameterType.OBJECT, + form=ToolParameter.ToolParameterForm.LLM, + llm_description="Payload", + required=False, + input_schema={ + "type": "object", + "properties": {"nested": {"type": "string"}}, + }, + ) + tool.entity.parameters = [query_parameter, region_parameter, hidden_parameter, file_parameter, payload_parameter] + + query_override = ToolParameter.get_simple_instance( + name="query", + llm_description="Runtime query", + typ=ToolParameter.ToolParameterType.STRING, + required=True, + ) + tool.runtime_parameter_overrides = [query_override] + + schema = tool.get_llm_parameters_json_schema() + + assert schema == { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Runtime query"}, + "region": { + "type": "string", + "description": "Search region", + "enum": ["global", "cn"], + }, + "payload": { + "type": "object", + "properties": {"nested": {"type": "string"}}, + "description": "Payload", + }, + }, + "required": ["query"], + } + + schema["properties"]["payload"]["properties"]["nested"]["type"] = "number" + assert payload_parameter.input_schema == { + "type": "object", + "properties": {"nested": {"type": "string"}}, + } def test_message_factory_helpers(): diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 7ddb5552a8..02e5d2fe8a 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -2,6 +2,7 @@ from dataclasses import replace import pytest +from clients.agent_backend import DIFY_EXECUTION_CONTEXT_LAYER_ID from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom from core.workflow.nodes.agent_v2.runtime_request_builder import ( WorkflowAgentRuntimeBuildContext, @@ -93,9 +94,10 @@ def test_builds_create_run_request_from_agent_soul_and_node_job(): result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(_context()) dumped = result.request.model_dump(mode="json") - assert dumped["execution_context"]["agent_id"] == "agent-1" - assert dumped["execution_context"]["agent_config_version_id"] == "snapshot-1" - assert dumped["execution_context"]["invoke_from"] == "single_step" + layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} + assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["agent_id"] == "agent-1" + assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["agent_config_version_id"] == "snapshot-1" + assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["invoke_from"] == "single_step" assert dumped["idempotency_key"] == "run-1:node-exec-1" assert dumped["composition"]["layers"][0]["config"]["prefix"] == "You are careful." assert dumped["composition"]["layers"][1]["config"]["prefix"] == "Use the previous output." @@ -145,7 +147,8 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) dumped = result.request.model_dump(mode="json") - assert dumped["execution_context"]["invoke_from"] == "workflow_run" + layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} + assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["invoke_from"] == "workflow_run" assert dumped["idempotency_key"] == "node-exec-1" output_schema = dumped["composition"]["layers"][-1]["config"]["json_schema"] assert output_schema["properties"]["report"]["properties"]["file_id"]["type"] == "string" diff --git a/dify-agent/docs/agenton/index.md b/dify-agent/docs/agenton/index.md index f96db54256..2af61df226 100644 --- a/dify-agent/docs/agenton/index.md +++ b/dify-agent/docs/agenton/index.md @@ -2,5 +2,3 @@ - [User guide](guide/index.md) explains how to compose layers, register config-backed plugins, use system/user prompts, and snapshot sessions. -- [API reference](api/index.md) lists the public Agenton classes, methods, and extension - points. diff --git a/dify-agent/docs/dify-agent/get-started/index.md b/dify-agent/docs/dify-agent/get-started/index.md index 517552e903..ff755aa183 100644 --- a/dify-agent/docs/dify-agent/get-started/index.md +++ b/dify-agent/docs/dify-agent/get-started/index.md @@ -111,11 +111,10 @@ import sys from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig from dify_agent.client import Client +from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig from dify_agent.layers.dify_plugin import ( - DIFY_PLUGIN_LAYER_TYPE_ID, DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DifyPluginLLMLayerConfig, - DifyPluginLayerConfig, ) from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, RunComposition, RunLayerSpec @@ -147,19 +146,20 @@ def build_request() -> CreateRunRequest: config=PromptLayerConfig(prefix=SYSTEM_PROMPT, user=USER_PROMPT), ), RunLayerSpec( - name="plugin", - type=DIFY_PLUGIN_LAYER_TYPE_ID, - config=DifyPluginLayerConfig( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig( tenant_id=TENANT_ID, - plugin_id=PLUGIN_ID, user_id=USER_ID, + invoke_from="workflow_run", ), ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID, - deps={"plugin": "plugin"}, + deps={"execution_context": "execution_context"}, config=DifyPluginLLMLayerConfig( + plugin_id=PLUGIN_ID, model_provider=MODEL_PROVIDER, model=MODEL_NAME, credentials=MODEL_CREDENTIALS, diff --git a/dify-agent/docs/dify-agent/guide/index.md b/dify-agent/docs/dify-agent/guide/index.md index 012bd3a598..e191caa613 100644 --- a/dify-agent/docs/dify-agent/guide/index.md +++ b/dify-agent/docs/dify-agent/guide/index.md @@ -61,9 +61,11 @@ record TTL so active runs that keep producing events remain observable. ## Scheduling and shutdown semantics -`POST /runs` validates the composition, persists a `running` run record, and starts -an `asyncio` task in the same process. There is no Redis job stream, consumer -group, pending reclaim, or automatic retry layer. +`POST /runs` persists a `running` run record and starts an `asyncio` task in the +same process. There is no Redis job stream, consumer group, pending reclaim, or +automatic retry layer. Request-shaped runtime failures such as bad composition, +prompt, output, or snapshot inputs are reported later as failed runs rather than +rejected synchronously once the request DTO itself is accepted. During FastAPI shutdown the scheduler rejects new runs, waits up to `DIFY_AGENT_SHUTDOWN_GRACE_SECONDS` for active tasks, then cancels remaining tasks diff --git a/dify-agent/docs/dify-agent/index.md b/dify-agent/docs/dify-agent/index.md index 39cedf74b9..2683fa78bd 100644 --- a/dify-agent/docs/dify-agent/index.md +++ b/dify-agent/docs/dify-agent/index.md @@ -4,5 +4,4 @@ Dify Agent hosts Agenton-composed Pydantic AI runs behind a FastAPI API. Its source code stays under `src/dify_agent`, while framework-neutral Agenton code stays under `src/agenton` and `src/agenton_collections`. -See the [operations guide](guide/index.md) for local server behavior and the -[run API](api/index.md) for request and event schemas. +See the [operations guide](guide/index.md) for local server behavior. diff --git a/dify-agent/docs/dify-agent/user-manual/execution-context-layer/index.md b/dify-agent/docs/dify-agent/user-manual/execution-context-layer/index.md new file mode 100644 index 0000000000..e73fd3c19b --- /dev/null +++ b/dify-agent/docs/dify-agent/user-manual/execution-context-layer/index.md @@ -0,0 +1,67 @@ +# Execution context layer + +The execution-context layer carries shared Dify run identifiers plus the tenant +and optional user context needed for plugin-daemon calls. Server settings still +provide the plugin daemon URL and API key. + +Use it together with a [plugin LLM layer](../plugin-llm-layer/index.md) and, +when the caller wants Dify tools exposed to the model, a +[plugin tool layer](../plugin-tool-layer/index.md). Both business layers depend +on this layer to reach the plugin daemon. + +## Config fields + +| Field | Type | Meaning | +| --- | --- | --- | +| `tenant_id` | `str` | Dify tenant/workspace id used when calling the plugin daemon. | +| `user_id` | `str \| None` | Optional end-user id passed through to the plugin daemon. | +| `invoke_from` | `Literal[...]` | Dify caller category recorded for observability and correlation. | +| `app_id` / `workflow_id` / `workflow_run_id` / `node_id` / `node_execution_id` / `conversation_id` / `agent_id` / `agent_config_version_id` / `trace_id` | `str \| None` | Optional Dify-owned execution identifiers forwarded with the run. | + +The execution-context layer type id is `dify.execution_context`. + +## Basic usage + +```python {test="skip" lint="skip"} +from dify_agent.layers.execution_context import ( + DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + DifyExecutionContextLayerConfig, +) +from dify_agent.protocol import RunLayerSpec + + +execution_context_layer = RunLayerSpec( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig( + tenant_id="replace-with-tenant-id", + user_id="replace-with-user-id", + invoke_from="workflow_run", + ), +) +``` + +If you do not need a user id, omit `user_id` or pass `None`. Most optional +execution identifiers may also be omitted when they are not available. + +## Server-side settings + +The execution-context layer config does not include daemon transport settings. +Configure these on the Dify Agent server instead: + +```env +DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002 +DIFY_AGENT_PLUGIN_DAEMON_API_KEY=replace-with-plugin-daemon-server-key +``` + +This keeps server credentials out of client-submitted layer config and out of +session snapshots. + +## Notes + +- The execution-context layer does not open, cache, close, or snapshot HTTP clients. +- Concrete `plugin_id` values belong to the business layer that invokes the + daemon: the plugin LLM layer for model calls and each plugin tool config for + tool calls. +- The conventional layer name is `execution_context`. If you use another name, + point the LLM and tool layer dependencies at that name. diff --git a/dify-agent/docs/dify-agent/user-manual/plugin-layer/index.md b/dify-agent/docs/dify-agent/user-manual/plugin-layer/index.md deleted file mode 100644 index 2164da9882..0000000000 --- a/dify-agent/docs/dify-agent/user-manual/plugin-layer/index.md +++ /dev/null @@ -1,59 +0,0 @@ -# Plugin layer - -The plugin layer carries Dify plugin daemon identity for a run. It identifies the -tenant, plugin, and optional user context; server settings provide the plugin -daemon URL and API key. - -Use it together with a [plugin LLM layer](../plugin-llm-layer/index.md). The LLM -layer depends on this layer to reach the plugin daemon. - -## Config fields - -| Field | Type | Meaning | -| --- | --- | --- | -| `tenant_id` | `str` | Dify tenant/workspace id used when calling the plugin daemon. | -| `plugin_id` | `str` | Plugin id, for example `langgenius/openai`. | -| `user_id` | `str \| None` | Optional end-user id passed through to the plugin daemon. | - -The plugin layer type id is `dify.plugin`. - -## Basic usage - -```python {test="skip" lint="skip"} -from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LAYER_TYPE_ID, DifyPluginLayerConfig -from dify_agent.protocol import RunLayerSpec - - -plugin_layer = RunLayerSpec( - name="plugin", - type=DIFY_PLUGIN_LAYER_TYPE_ID, - config=DifyPluginLayerConfig( - tenant_id="replace-with-tenant-id", - plugin_id="langgenius/openai", - user_id="replace-with-user-id", - ), -) -``` - -If you do not need a user id, omit `user_id` or pass `None`. - -## Server-side settings - -The plugin layer config does not include daemon transport settings. Configure -these on the Dify Agent server instead: - -```env -DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002 -DIFY_AGENT_PLUGIN_DAEMON_API_KEY=replace-with-plugin-daemon-server-key -``` - -This keeps server credentials out of client-submitted layer config and out of -session snapshots. - -## Notes - -- The plugin layer does not open, cache, close, or snapshot HTTP clients. -- `plugin_id` selects the plugin package. The business model provider and model - name belong to the plugin LLM layer, not this layer. -- The conventional layer name is `plugin`. If you use another name, point the LLM - layer dependency at that name. diff --git a/dify-agent/docs/dify-agent/user-manual/plugin-llm-layer/index.md b/dify-agent/docs/dify-agent/user-manual/plugin-llm-layer/index.md index 624d7cfd14..889d67778b 100644 --- a/dify-agent/docs/dify-agent/user-manual/plugin-llm-layer/index.md +++ b/dify-agent/docs/dify-agent/user-manual/plugin-llm-layer/index.md @@ -1,17 +1,18 @@ # Plugin LLM layer -The plugin LLM layer selects the model provider, model name, provider credentials, -and optional model settings for the current run. Dify Agent reads the model from -the reserved layer name `llm`. +The plugin LLM layer selects the plugin package, model provider, model name, +provider credentials, and optional model settings for the current run. Dify +Agent reads the model from the reserved layer name `llm`. -It must depend on a [plugin layer](../plugin-layer/index.md), because the plugin -layer supplies the daemon identity and transport context. +It must depend on an [execution context layer](../execution-context-layer/index.md), +because that layer supplies the daemon identity and transport context. ## Config fields | Field | Type | Meaning | | --- | --- | --- | -| `model_provider` | `str` | Provider name inside the selected plugin. Use the value of `DIFY_AGENT_PROVIDER` from `dify-agent/.env`. | +| `plugin_id` | `str` | Plugin package id, for example `langgenius/openai`. | +| `model_provider` | `str` | Provider name inside `plugin_id`. Use the value of `DIFY_AGENT_PROVIDER` from `dify-agent/.env`. | | `model` | `str` | Model name. Use the value of `DIFY_AGENT_MODEL_NAME` from `dify-agent/.env`. | | `credentials` | `dict[str, str \| int \| float \| bool \| None]` | Provider-specific credential object. | | `model_settings` | `ModelSettings \| None` | Optional pydantic-ai model settings. | @@ -27,12 +28,14 @@ from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, RunLayerSpec MODEL_PROVIDER = "replace-with-provider-from-dify-agent-env" MODEL_NAME = "replace-with-model-from-dify-agent-env" +PLUGIN_ID = "langgenius/openai" llm_layer = RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID, - deps={"plugin": "plugin"}, + deps={"execution_context": "execution_context"}, config=DifyPluginLLMLayerConfig( + plugin_id=PLUGIN_ID, model_provider=MODEL_PROVIDER, model=MODEL_NAME, credentials={"api_key": "replace-with-provider-key"}, @@ -40,29 +43,30 @@ llm_layer = RunLayerSpec( ) ``` -`deps={"plugin": "plugin"}` means: bind the LLM layer's dependency field named -`plugin` to the composition layer named `plugin`. +`deps={"execution_context": "execution_context"}` means: bind the LLM layer's +dependency field named `execution_context` to the composition layer named +`execution_context`. Set `MODEL_PROVIDER` and `MODEL_NAME` to the same values as `DIFY_AGENT_PROVIDER` and `DIFY_AGENT_MODEL_NAME` in `dify-agent/.env`. ## Complete minimal model composition -Most runs include a prompt, plugin context, and LLM layer: +Most runs include a prompt, execution-context layer, and LLM layer: ```python {test="skip" lint="skip"} from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig +from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig from dify_agent.layers.dify_plugin import ( - DIFY_PLUGIN_LAYER_TYPE_ID, DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DifyPluginLLMLayerConfig, - DifyPluginLayerConfig, ) from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, RunComposition, RunLayerSpec MODEL_PROVIDER = "replace-with-provider-from-dify-agent-env" MODEL_NAME = "replace-with-model-from-dify-agent-env" +PLUGIN_ID = "langgenius/openai" composition = RunComposition( layers=[ @@ -72,18 +76,19 @@ composition = RunComposition( config=PromptLayerConfig(prefix="You are concise.", user="Say hello."), ), RunLayerSpec( - name="plugin", - type=DIFY_PLUGIN_LAYER_TYPE_ID, - config=DifyPluginLayerConfig( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig( tenant_id="replace-with-tenant-id", - plugin_id="langgenius/openai", + invoke_from="workflow_run", ), ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID, - deps={"plugin": "plugin"}, + deps={"execution_context": "execution_context"}, config=DifyPluginLLMLayerConfig( + plugin_id=PLUGIN_ID, model_provider=MODEL_PROVIDER, model=MODEL_NAME, credentials={"api_key": "replace-with-provider-key"}, @@ -96,6 +101,9 @@ composition = RunComposition( ## Notes - The model layer must use the reserved name `llm` (`DIFY_AGENT_MODEL_LAYER_ID`). +- `plugin_id` belongs here because model calls are plugin-specific business + calls. The shared execution-context layer only carries Dify run and + tenant/user daemon context. - Credential shape depends on the selected plugin provider; the OpenAI-style `api_key` field above is only an example. - Client-submitted model credentials remain in the scheduled request memory and diff --git a/dify-agent/docs/dify-agent/user-manual/plugin-tool-layer/index.md b/dify-agent/docs/dify-agent/user-manual/plugin-tool-layer/index.md new file mode 100644 index 0000000000..a7618462c9 --- /dev/null +++ b/dify-agent/docs/dify-agent/user-manual/plugin-tool-layer/index.md @@ -0,0 +1,130 @@ +# Plugin tool layer + +The plugin tool layer exposes Dify plugin tools to the model. It is designed for +Dify API to build after it has resolved a user's tool selections, plugin daemon +declarations, credentials, and manual/runtime inputs. + +Unlike the plugin LLM layer, this layer may contain tools from multiple plugin +packages. Each tool config carries its own `plugin_id`, while the shared +[execution context layer](../execution-context-layer/index.md) still carries +only tenant/user daemon context. + +## Responsibilities + +Dify API prepares the tool config before submitting the run request: + +- resolve the selected provider and tool name; +- merge declared parameters with runtime parameters; +- produce the model-visible JSON schema; +- provide hidden/manual `runtime_parameters` and credentials; +- choose the daemon `credential_type` for invocation. + +Dify Agent consumes that prepared config. At run time it validates required +hidden inputs, applies defaults, casts invocation values, calls plugin daemon, +and turns tool responses into model observations. + +## Config fields + +The plugin tools layer type id is `dify.plugin.tools`. + +`DifyPluginToolsLayerConfig` contains a list of `DifyPluginToolConfig` objects: + +| Field | Type | Meaning | +| --- | --- | --- | +| `tools` | `list[DifyPluginToolConfig]` | Prepared plugin tools to expose to the model. | + +Each tool config has these fields: + +| Field | Type | Meaning | +| --- | --- | --- | +| `plugin_id` | `str` | Plugin package id for this tool, for example `langgenius/wikipedia`. | +| `provider` | `str` | Tool provider name inside the plugin. | +| `tool_name` | `str` | Daemon tool name to invoke. | +| `credential_type` | `"api-key" \| "oauth2" \| "unauthorized"` | Credential mode sent to plugin daemon. | +| `name` | `str \| None` | Optional model-visible tool name. Defaults to `tool_name`. | +| `description` | `str \| None` | Optional model-visible description. Defaults to the tool name. | +| `credentials` | `dict[str, str \| int \| float \| bool \| None]` | Provider-specific tool credentials. | +| `runtime_parameters` | `dict[str, JsonValue]` | Hidden/manual values merged into daemon invocation but omitted from the model schema. | +| `parameters` | `list[DifyPluginToolParameter]` | API-prepared effective parameter declarations used for validation, defaults, and casting. | +| `parameters_json_schema` | `dict[str, JsonValue]` | API-prepared JSON schema shown to the model. | + +## Example: Dify API prepared Wikipedia tool + +```python {test="skip" lint="skip"} +from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig +from dify_agent.layers.dify_plugin import ( + DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, + DifyPluginToolConfig, + DifyPluginToolParameter, + DifyPluginToolsLayerConfig, +) +from dify_agent.protocol import RunComposition, RunLayerSpec + + +# Dify API side: resolve the selected tool into the API-side Tool runtime first, +# for example with ToolManager.get_agent_tool_runtime(...). Then adapt its +# effective ToolParameter objects at the protocol boundary. Dify Agent accepts +# both ToolParameter attribute objects and ToolParameter.model_dump(mode="json") +# dictionaries, ignoring API-only fields such as label and human_description. +tool_runtime = ToolManager.get_agent_tool_runtime(...) +effective_parameters = tool_runtime.get_merged_runtime_parameters() +prepared_parameters = [ + DifyPluginToolParameter.model_validate(parameter) + # If the API serializes first, use: + # DifyPluginToolParameter.model_validate(parameter.model_dump(mode="json")) + for parameter in effective_parameters +] +parameters_json_schema = tool_runtime.get_llm_parameters_json_schema() + +composition = RunComposition( + layers=[ + RunLayerSpec( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig( + tenant_id="replace-with-tenant-id", + user_id="replace-with-user-id", + invoke_from="workflow_run", + ), + ), + RunLayerSpec( + name="tools", + type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, + deps={"execution_context": "execution_context"}, + config=DifyPluginToolsLayerConfig( + tools=[ + DifyPluginToolConfig( + plugin_id="langgenius/wikipedia", + provider="wikipedia", + tool_name="wikipedia_search", + credential_type="unauthorized", + name="wikipedia_search", + description="Search Wikipedia for relevant pages.", + parameters=prepared_parameters, + runtime_parameters={"language": "en"}, + parameters_json_schema=parameters_json_schema, + ) + ] + ), + ), + ] +) +``` + +`deps={"execution_context": "execution_context"}` means: bind the tool layer's +dependency field named `execution_context` to the composition layer named +`execution_context`. + +## Notes for Dify API callers + +- Do not ask Dify Agent to discover tool declarations. Resolve and prepare them + in API before creating the run. +- `parameters` should include all effective parameters, including hidden/manual + ones needed for validation and default application. +- `parameters_json_schema` should include only model-visible parameters. Omit + hidden/manual parameters and file/system-file parameters unless they are truly + intended for model input. +- `runtime_parameters` should contain hidden/manual values selected by the user + or derived from workflow variables. +- Put each tool's `plugin_id` on the tool config. The shared execution-context + layer has no package-specific identity. diff --git a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py index a3d0474b46..fb07c352d1 100644 --- a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py +++ b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py @@ -17,12 +17,11 @@ import asyncio from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig from dify_agent.client import Client +from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig from dify_agent.layers.dify_plugin import ( - DIFY_PLUGIN_LAYER_TYPE_ID, DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DifyPluginCredentialValue, DifyPluginLLMLayerConfig, - DifyPluginLayerConfig, ) from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, RunComposition, RunLayerSpec @@ -50,20 +49,67 @@ async def main() -> None: ), ), RunLayerSpec( - name="plugin", - type=DIFY_PLUGIN_LAYER_TYPE_ID, - config=DifyPluginLayerConfig(tenant_id=TENANT_ID, plugin_id=PLUGIN_ID), + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig( + tenant_id=TENANT_ID, + invoke_from="workflow_run", + ), ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID, - deps={"plugin": "plugin"}, + deps={"execution_context": "execution_context"}, config=DifyPluginLLMLayerConfig( + plugin_id=PLUGIN_ID, model_provider=PLUGIN_PROVIDER, model=MODEL_NAME, credentials=MODEL_CREDENTIALS, ), ), + # Minimal plugin-tools example. API callers should pass + # prepared parameters + JSON schema instead of relying on + # dify-agent to fetch and merge daemon declarations. + # from dify_agent.layers.dify_plugin import ( + # DifyPluginToolConfig, + # DifyPluginToolParameter, + # DifyPluginToolParameterForm, + # DifyPluginToolParameterType, + # DifyPluginToolsLayerConfig, + # ) + # RunLayerSpec( + # name="tools", + # type="dify.plugin.tools", + # deps={"execution_context": "execution_context"}, + # config=DifyPluginToolsLayerConfig( + # tools=[ + # DifyPluginToolConfig( + # plugin_id="langgenius/search", + # provider="search", + # tool_name="web_search", + # credential_type="api-key", + # credentials={"api_key": "replace-with-tool-key"}, + # runtime_parameters={"site": "docs.dify.ai"}, + # parameters=[ + # DifyPluginToolParameter( + # name="query", + # type=DifyPluginToolParameterType.STRING, + # form=DifyPluginToolParameterForm.LLM, + # required=True, + # llm_description="Search query", + # ), + # ], + # parameters_json_schema={ + # "type": "object", + # "properties": { + # "query": {"type": "string", "description": "Search query"} + # }, + # "required": ["query"], + # }, + # ) + # ] + # ), + # ), ], ), ) diff --git a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py index 3c789571f1..90ae65d39b 100644 --- a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py +++ b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py @@ -10,12 +10,11 @@ assuming the original request was not accepted. from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig from dify_agent.client import Client +from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig from dify_agent.layers.dify_plugin import ( - DIFY_PLUGIN_LAYER_TYPE_ID, DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DifyPluginCredentialValue, DifyPluginLLMLayerConfig, - DifyPluginLayerConfig, ) from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, RunComposition, RunLayerSpec @@ -43,20 +42,67 @@ def main() -> None: ), ), RunLayerSpec( - name="plugin", - type=DIFY_PLUGIN_LAYER_TYPE_ID, - config=DifyPluginLayerConfig(tenant_id=TENANT_ID, plugin_id=PLUGIN_ID), + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig( + tenant_id=TENANT_ID, + invoke_from="workflow_run", + ), ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID, - deps={"plugin": "plugin"}, + deps={"execution_context": "execution_context"}, config=DifyPluginLLMLayerConfig( + plugin_id=PLUGIN_ID, model_provider=PLUGIN_PROVIDER, model=MODEL_NAME, credentials=MODEL_CREDENTIALS, ), ), + # Minimal plugin-tools example. API callers should pass + # prepared parameters + JSON schema instead of relying on + # dify-agent to fetch and merge daemon declarations. + # from dify_agent.layers.dify_plugin import ( + # DifyPluginToolConfig, + # DifyPluginToolParameter, + # DifyPluginToolParameterForm, + # DifyPluginToolParameterType, + # DifyPluginToolsLayerConfig, + # ) + # RunLayerSpec( + # name="tools", + # type="dify.plugin.tools", + # deps={"execution_context": "execution_context"}, + # config=DifyPluginToolsLayerConfig( + # tools=[ + # DifyPluginToolConfig( + # plugin_id="langgenius/search", + # provider="search", + # tool_name="web_search", + # credential_type="api-key", + # credentials={"api_key": "replace-with-tool-key"}, + # runtime_parameters={"site": "docs.dify.ai"}, + # parameters=[ + # DifyPluginToolParameter( + # name="query", + # type=DifyPluginToolParameterType.STRING, + # form=DifyPluginToolParameterForm.LLM, + # required=True, + # llm_description="Search query", + # ), + # ], + # parameters_json_schema={ + # "type": "object", + # "properties": { + # "query": {"type": "string", "description": "Search query"} + # }, + # "required": ["query"], + # }, + # ) + # ] + # ), + # ), ], ), ) diff --git a/dify-agent/mkdocs.yml b/dify-agent/mkdocs.yml index c2fae8487f..ab66d3e72c 100644 --- a/dify-agent/mkdocs.yml +++ b/dify-agent/mkdocs.yml @@ -11,19 +11,18 @@ nav: - Agenton: - Overview: agenton/index.md - Guide: agenton/guide/index.md - - API Reference: agenton/api/index.md - Examples: agenton/examples/index.md - Dify Agent: - Overview: dify-agent/index.md - User Manual: - Get Started: dify-agent/get-started/index.md - Prompt Layer: dify-agent/user-manual/prompt-layer/index.md - - Plugin Layer: dify-agent/user-manual/plugin-layer/index.md + - Execution Context Layer: dify-agent/user-manual/execution-context-layer/index.md - Plugin LLM Layer: dify-agent/user-manual/plugin-llm-layer/index.md + - Plugin Tool Layer: dify-agent/user-manual/plugin-tool-layer/index.md - History Layer: dify-agent/user-manual/history-layer/index.md - Structured Output Layer: dify-agent/user-manual/structured-output-layer/index.md - Operations Guide: dify-agent/guide/index.md - - Run API: dify-agent/api/index.md - Examples: dify-agent/examples/index.md theme: diff --git a/dify-agent/src/dify_agent/adapters/llm/provider.py b/dify-agent/src/dify_agent/adapters/llm/provider.py index 6e7b92f646..a210cce1e3 100644 --- a/dify-agent/src/dify_agent/adapters/llm/provider.py +++ b/dify-agent/src/dify_agent/adapters/llm/provider.py @@ -8,7 +8,6 @@ this provider. from __future__ import annotations -import json from collections.abc import AsyncIterator, Callable, Mapping from dataclasses import dataclass, field from typing import NoReturn @@ -22,6 +21,12 @@ from typing_extensions import override from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError, UnexpectedModelBehavior, UserError from pydantic_ai.providers import Provider +from dify_agent.plugin_daemon_transport import ( + decode_plugin_daemon_error_payload, + to_plugin_daemon_jsonable, + unwrap_plugin_daemon_error, +) + _DEFAULT_DAEMON_TIMEOUT: float | httpx.Timeout | None = 600.0 @@ -83,7 +88,7 @@ class DifyPluginDaemonLLMClient: request_data: Mapping[str, object], response_model: type[T], ) -> AsyncIterator[T]: - payload: dict[str, object] = {"data": _to_jsonable(request_data)} + payload: dict[str, object] = {"data": to_plugin_daemon_jsonable(request_data)} if self.user_id is not None: payload["user_id"] = self.user_id @@ -97,14 +102,18 @@ class DifyPluginDaemonLLMClient: async with self.http_client.stream("POST", url, headers=headers, json=payload) as response: if response.is_error: body = (await response.aread()).decode("utf-8", errors="replace") - error = _decode_plugin_daemon_error_payload(body) + error = decode_plugin_daemon_error_payload(body) if error is not None: - _raise_plugin_daemon_error( - model_name=model_name, + resolved_error = unwrap_plugin_daemon_error( error_type=error["error_type"], message=error["message"], + ) + _raise_plugin_daemon_error( + model_name=model_name, + error_type=resolved_error["error_type"], + message=resolved_error["message"], status_code=response.status_code, - body=error, + body=resolved_error, ) raise ModelHTTPError(response.status_code, model_name, body or None) @@ -117,13 +126,17 @@ class DifyPluginDaemonLLMClient: wrapped = PluginDaemonBasicResponse.model_validate_json(line) if wrapped.code != 0: - error = _decode_plugin_daemon_error_payload(wrapped.message) + error = decode_plugin_daemon_error_payload(wrapped.message) if error is not None: - _raise_plugin_daemon_error( - model_name=model_name, + resolved_error = unwrap_plugin_daemon_error( error_type=error["error_type"], message=error["message"], - body=error, + ) + _raise_plugin_daemon_error( + model_name=model_name, + error_type=resolved_error["error_type"], + message=resolved_error["message"], + body=resolved_error, ) raise ModelAPIError( model_name, @@ -199,32 +212,6 @@ class DifyPluginDaemonProvider(Provider[DifyPluginDaemonLLMClient]): return self._client -def _to_jsonable(value: object) -> object: - if isinstance(value, BaseModel): - return value.model_dump(mode="json") - if isinstance(value, dict): - return {key: _to_jsonable(item) for key, item in value.items()} - if isinstance(value, list | tuple): - return [_to_jsonable(item) for item in value] - return value - - -def _decode_plugin_daemon_error_payload(raw_message: str) -> dict[str, str] | None: - try: - parsed = json.loads(raw_message) - except json.JSONDecodeError: - return None - - if not isinstance(parsed, dict): - return None - - error_type = parsed.get("error_type") - message = parsed.get("message") - if not isinstance(error_type, str) or not isinstance(message, str): - return None - return {"error_type": error_type, "message": message} - - def _raise_plugin_daemon_error( *, model_name: str, @@ -236,17 +223,6 @@ def _raise_plugin_daemon_error( http_error_body = body or {"error_type": error_type, "message": message} match error_type: - case "PluginInvokeError": - nested_error = _decode_plugin_daemon_error_payload(message) - if nested_error is not None: - _raise_plugin_daemon_error( - model_name=model_name, - error_type=nested_error["error_type"], - message=nested_error["message"], - status_code=status_code, - body=nested_error, - ) - raise ModelAPIError(model_name, message) case "PluginDaemonUnauthorizedError" | "InvokeAuthorizationError": raise ModelHTTPError(status_code or 401, model_name, http_error_body) case "PluginPermissionDeniedError": diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/__init__.py b/dify-agent/src/dify_agent/layers/dify_plugin/__init__.py index 5b2f1dccce..bd719cea8f 100644 --- a/dify-agent/src/dify_agent/layers/dify_plugin/__init__.py +++ b/dify-agent/src/dify_agent/layers/dify_plugin/__init__.py @@ -1,21 +1,35 @@ -"""Client-safe exports for Dify plugin DTOs and public layer type ids. +"""Client-safe exports for Dify plugin business-layer DTOs and type ids. Implementation layers live in sibling modules and require server-side runtime dependencies. Keep this package root import-safe for client-only installs. """ from dify_agent.layers.dify_plugin.configs import ( - DIFY_PLUGIN_LAYER_TYPE_ID, DIFY_PLUGIN_LLM_LAYER_TYPE_ID, + DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, DifyPluginCredentialValue, DifyPluginLLMLayerConfig, - DifyPluginLayerConfig, + DifyPluginToolCredentialType, + DifyPluginToolConfig, + DifyPluginToolOption, + DifyPluginToolParameter, + DifyPluginToolParameterForm, + DifyPluginToolParameterType, + DifyPluginToolsLayerConfig, + DifyPluginToolValue, ) __all__ = [ - "DIFY_PLUGIN_LAYER_TYPE_ID", "DIFY_PLUGIN_LLM_LAYER_TYPE_ID", + "DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID", "DifyPluginCredentialValue", "DifyPluginLLMLayerConfig", - "DifyPluginLayerConfig", + "DifyPluginToolCredentialType", + "DifyPluginToolConfig", + "DifyPluginToolOption", + "DifyPluginToolParameter", + "DifyPluginToolParameterForm", + "DifyPluginToolParameterType", + "DifyPluginToolsLayerConfig", + "DifyPluginToolValue", ] diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/configs.py b/dify-agent/src/dify_agent/layers/dify_plugin/configs.py index 5fff7dde51..25651482a5 100644 --- a/dify-agent/src/dify_agent/layers/dify_plugin/configs.py +++ b/dify-agent/src/dify_agent/layers/dify_plugin/configs.py @@ -1,38 +1,111 @@ -"""Client-safe DTOs for Dify plugin-backed Agenton layers. +"""Client-safe DTOs for Dify plugin-backed Agenton business layers. This module intentionally contains only public config schemas and scalar type -aliases plus stable layer type identifiers. Runtime objects such as HTTP -clients, server settings, and adapter implementations live in sibling -implementation modules so clients can build run requests without importing -server-only dependencies. +aliases plus stable plugin business-layer type identifiers. Runtime objects +such as HTTP clients, server settings, and adapter implementations live in +sibling implementation modules so clients can build run requests without +importing server-only dependencies. + +Shared tenant/user/run context now lives in the sibling +``dify_agent.layers.execution_context`` package. This module only covers the +plugin-backed LLM and tools layers that invoke daemon features with concrete +``plugin_id`` values. Tool configs also carry the API-side prepared parameter +declarations and model-visible JSON schema so the agent runtime does not have to +re-fetch and re-merge tool declarations at execution time. """ -from typing import ClassVar, Final, TypeAlias +from enum import StrEnum +from typing import ClassVar, Final, Literal, TypeAlias -from pydantic import ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator from pydantic_ai.settings import ModelSettings from agenton.layers import LayerConfig DifyPluginCredentialValue: TypeAlias = str | int | float | bool | None -DIFY_PLUGIN_LAYER_TYPE_ID: Final[str] = "dify.plugin" +DifyPluginToolCredentialType: TypeAlias = Literal["api-key", "oauth2", "unauthorized"] +DifyPluginToolValue: TypeAlias = JsonValue DIFY_PLUGIN_LLM_LAYER_TYPE_ID: Final[str] = "dify.plugin.llm" +DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID: Final[str] = "dify.plugin.tools" -class DifyPluginLayerConfig(LayerConfig): - """Public config for the plugin daemon tenant/plugin context layer.""" +class DifyPluginToolOption(BaseModel): + """Selectable tool option value exposed to the model. - tenant_id: str - plugin_id: str - user_id: str | None = None + The DTO also accepts API-side option dumps and attribute objects. Fields + such as ``label`` or ``icon`` are intentionally ignored because Dify Agent + only preserves the normalized option ``value`` for tool invocation and + model-visible schema generation. + """ - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True) + value: str + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore", from_attributes=True) + + @field_validator("value", mode="before") + @classmethod + def stringify_value(cls, value: object) -> str: + return value if isinstance(value, str) else str(value) + + +class DifyPluginToolParameterType(StrEnum): + STRING = "string" + NUMBER = "number" + BOOLEAN = "boolean" + SELECT = "select" + SECRET_INPUT = "secret-input" + FILE = "file" + FILES = "files" + APP_SELECTOR = "app-selector" + MODEL_SELECTOR = "model-selector" + ANY = "any" + DYNAMIC_SELECT = "dynamic-select" + CHECKBOX = "checkbox" + SYSTEM_FILES = "system-files" + ARRAY = "array" + OBJECT = "object" + + def as_normal_type(self) -> str: + if self in { + DifyPluginToolParameterType.SECRET_INPUT, + DifyPluginToolParameterType.SELECT, + DifyPluginToolParameterType.CHECKBOX, + }: + return "string" + return self.value + + +class DifyPluginToolParameterForm(StrEnum): + SCHEMA = "schema" + FORM = "form" + LLM = "llm" + + +class DifyPluginToolParameter(BaseModel): + """Prepared tool parameter declaration supplied by the API side. + + The DTO intentionally accepts both API-side ``ToolParameter`` dumps and + attribute objects so callers can adapt existing tool runtime declarations + without coupling Dify Agent to API-internal model classes. + """ + + name: str + type: DifyPluginToolParameterType + form: DifyPluginToolParameterForm + required: bool = False + default: DifyPluginToolValue = None + llm_description: str | None = None + input_schema: dict[str, JsonValue] | None = None + options: list[DifyPluginToolOption] = Field(default_factory=list) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore", from_attributes=True) class DifyPluginLLMLayerConfig(LayerConfig): - """Public config for selecting a business provider/model from a plugin.""" + """Public config for selecting a plugin-backed business provider/model.""" + plugin_id: str model_provider: str model: str credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict) @@ -41,10 +114,64 @@ class DifyPluginLLMLayerConfig(LayerConfig): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True) +class DifyPluginToolConfig(LayerConfig): + """Public config for exposing one plugin tool to the agent model. + + ``credential_type`` is an explicit caller-supplied daemon transport choice, + not an auto-discovered property. It must match the actual credential mode of + ``credentials`` for the configured plugin tool, for example ``"api-key"`` + versus ``"oauth2"``. A wrong value can make invocation fail at runtime even + when the config itself validates successfully. + + ``runtime_parameters`` mirrors Dify's agent-node hidden/manual tool inputs: + those values are merged into the actual daemon invocation but omitted from + the tool schema shown to the model. + + ``parameters`` and ``parameters_json_schema`` are API-side prepared tool + declaration artifacts. They let the agent runtime validate hidden/default + inputs and expose the correct LLM-facing schema without re-fetching or + re-merging daemon declarations at run time. + """ + + plugin_id: str + provider: str + tool_name: str + credential_type: DifyPluginToolCredentialType + name: str | None = None + description: str | None = None + credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict) + runtime_parameters: dict[str, DifyPluginToolValue] = Field(default_factory=dict) + parameters: list[DifyPluginToolParameter] = Field(default_factory=list) + parameters_json_schema: dict[str, JsonValue] = Field( + default_factory=lambda: {"type": "object", "properties": {}, "required": []} + ) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True) + + +class DifyPluginToolsLayerConfig(LayerConfig): + """Public config for the Dify plugin tools layer. + + Callers configure the tools layer with this wrapper object and supply one + or more prepared ``DifyPluginToolConfig`` entries in ``tools``. + """ + + tools: list[DifyPluginToolConfig] = Field(default_factory=list) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True) + + __all__ = [ - "DIFY_PLUGIN_LAYER_TYPE_ID", "DIFY_PLUGIN_LLM_LAYER_TYPE_ID", + "DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID", "DifyPluginCredentialValue", "DifyPluginLLMLayerConfig", - "DifyPluginLayerConfig", + "DifyPluginToolCredentialType", + "DifyPluginToolConfig", + "DifyPluginToolOption", + "DifyPluginToolParameter", + "DifyPluginToolParameterForm", + "DifyPluginToolParameterType", + "DifyPluginToolsLayerConfig", + "DifyPluginToolValue", ] diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/llm_layer.py b/dify-agent/src/dify_agent/layers/dify_plugin/llm_layer.py index 4ac053df3f..48e6c5508d 100644 --- a/dify-agent/src/dify_agent/layers/dify_plugin/llm_layer.py +++ b/dify-agent/src/dify_agent/layers/dify_plugin/llm_layer.py @@ -1,15 +1,17 @@ """Dify plugin LLM model layer. This layer owns model capability resolution for Dify plugin-backed LLMs. It -depends on ``DifyPluginLayer`` for daemon identity through Agenton's direct -dependency binding and returns a Pydantic AI model adapter configured from the -public LLM layer DTO. Runtime code supplies the FastAPI lifespan-owned shared -HTTP client to ``get_model``; the layer does not own or discover live resources. -The daemon provider carries plugin transport identity, while the DTO's -``model_provider`` is passed to the adapter as request-level model identity. +depends on ``DifyExecutionContextLayer`` for shared daemon settings through +Agenton's direct dependency binding and returns a Pydantic AI model adapter +configured from the public LLM layer DTO. Runtime code supplies the FastAPI +lifespan-owned shared HTTP client to ``get_model``; the layer does not own or +discover live resources. The daemon provider carries plugin transport identity, +while the DTO's ``model_provider`` is passed to the adapter as request-level +model identity. """ from dataclasses import dataclass +from typing import ClassVar import httpx from typing_extensions import Self, override @@ -17,20 +19,20 @@ from typing_extensions import Self, override from agenton.layers import LayerDeps, PlainLayer from dify_agent.adapters.llm import DifyLLMAdapterModel from dify_agent.layers.dify_plugin.configs import DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DifyPluginLLMLayerConfig -from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer +from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer class DifyPluginLLMDeps(LayerDeps): """Dependencies required by ``DifyPluginLLMLayer``.""" - plugin: DifyPluginLayer # pyright: ignore[reportUninitializedInstanceVariable] + execution_context: DifyExecutionContextLayer # pyright: ignore[reportUninitializedInstanceVariable] @dataclass(slots=True) class DifyPluginLLMLayer(PlainLayer[DifyPluginLLMDeps, DifyPluginLLMLayerConfig]): """Layer that creates the Dify plugin-daemon Pydantic AI model.""" - type_id = DIFY_PLUGIN_LLM_LAYER_TYPE_ID + type_id: ClassVar[str] = DIFY_PLUGIN_LLM_LAYER_TYPE_ID config: DifyPluginLLMLayerConfig @@ -41,8 +43,11 @@ class DifyPluginLLMLayer(PlainLayer[DifyPluginLLMDeps, DifyPluginLLMLayerConfig] return cls(config=config) def get_model(self, *, http_client: httpx.AsyncClient) -> DifyLLMAdapterModel: - """Return the configured model using the directly bound plugin dependency.""" - provider = self.deps.plugin.create_daemon_provider(http_client=http_client) + """Return the configured model using the directly bound execution context.""" + provider = self.deps.execution_context.create_daemon_provider( + plugin_id=self.config.plugin_id, + http_client=http_client, + ) return DifyLLMAdapterModel( model=self.config.model, daemon_provider=provider, diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/plugin_layer.py b/dify-agent/src/dify_agent/layers/dify_plugin/plugin_layer.py deleted file mode 100644 index 71c649b6de..0000000000 --- a/dify-agent/src/dify_agent/layers/dify_plugin/plugin_layer.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Runtime Dify plugin context layer. - -The public config identifies tenant/plugin/user context only. Plugin daemon URL -and API key are server-side settings injected by the provider factory. The layer -is intentionally config/settings-only under Agenton's state-only core: it does -not open, cache, close, or snapshot HTTP clients, and its lifecycle hooks remain -the inherited no-op hooks. Runtime code passes the FastAPI lifespan-owned shared -``httpx.AsyncClient`` into ``create_daemon_provider`` for each model adapter. -Business model-provider names belong to the LLM layer/model request, not this -daemon context layer. -""" - -from dataclasses import dataclass - -import httpx -from typing_extensions import Self, override - -from agenton.layers import EmptyRuntimeState, NoLayerDeps, PlainLayer -from dify_agent.adapters.llm import DifyPluginDaemonProvider -from dify_agent.layers.dify_plugin.configs import DIFY_PLUGIN_LAYER_TYPE_ID, DifyPluginLayerConfig - - -@dataclass(slots=True) -class DifyPluginLayer(PlainLayer[NoLayerDeps, DifyPluginLayerConfig, EmptyRuntimeState]): - """Layer that carries plugin daemon identity without owning live resources.""" - - type_id = DIFY_PLUGIN_LAYER_TYPE_ID - - config: DifyPluginLayerConfig - daemon_url: str - daemon_api_key: str - - @classmethod - @override - def from_config(cls, config: DifyPluginLayerConfig) -> Self: - """Reject construction without server-injected daemon settings.""" - del config - raise TypeError("DifyPluginLayer requires server-side daemon settings and must use a provider factory.") - - @classmethod - def from_config_with_settings( - cls, - config: DifyPluginLayerConfig, - *, - daemon_url: str, - daemon_api_key: str, - ) -> Self: - """Create a plugin layer from public config plus server-only daemon settings.""" - return cls(config=config, daemon_url=daemon_url, daemon_api_key=daemon_api_key) - - def create_daemon_provider(self, *, http_client: httpx.AsyncClient) -> DifyPluginDaemonProvider: - """Return a daemon provider backed by the shared plugin daemon client. - - Raises: - RuntimeError: if ``http_client`` has already been closed. - """ - if http_client.is_closed: - raise RuntimeError("DifyPluginLayer.create_daemon_provider() requires an open shared HTTP client.") - return DifyPluginDaemonProvider( - tenant_id=self.config.tenant_id, - plugin_id=self.config.plugin_id, - plugin_daemon_url=self.daemon_url, - plugin_daemon_api_key=self.daemon_api_key, - user_id=self.config.user_id, - http_client=http_client, - ) - - -__all__ = ["DifyPluginLayer"] diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py b/dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py new file mode 100644 index 0000000000..db65265464 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py @@ -0,0 +1,333 @@ +"""Async plugin-daemon client for Dify plugin tool invocation. + +The agent runtime talks to the plugin daemon rather than importing provider SDKs +directly. The tools layer now consumes API-prepared declarations from config, so +this module only keeps the invoke-time boundary: + +- POST ``/plugin/{tenant_id}/dispatch/tool/invoke`` +- request headers ``X-Api-Key``, ``X-Plugin-ID``, and ``Content-Type`` +- top-level ``user_id`` forwarding when shared execution context includes one +- stream decoding and blob-chunk merging for agent observations + +The shared execution-context layer still owns tenant/user daemon context, while +each tool's own ``plugin_id`` determines the transport identity placed in +``X-Plugin-ID``. +""" + +from __future__ import annotations + +import base64 +from collections.abc import AsyncIterator, Mapping +from dataclasses import dataclass, field +from enum import StrEnum + +import httpx +from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator + +from dify_agent.layers.dify_plugin.configs import DifyPluginToolCredentialType +from dify_agent.plugin_daemon_transport import ( + decode_plugin_daemon_error_payload, + to_plugin_daemon_jsonable, + unwrap_plugin_daemon_error, +) + + +class PluginDaemonBasicResponse(BaseModel): + """Common plugin-daemon stream and JSON wrapper.""" + + code: int + message: str + data: object | None = None + + +@dataclass(slots=True) +class FileChunk: + """Buffer for accumulating streamed blob chunks.""" + + total_length: int + bytes_written: int = field(default=0, init=False) + data: bytearray = field(init=False) + + def __post_init__(self) -> None: + self.data = bytearray(self.total_length) + + +class DifyPluginToolInvokeMessage(BaseModel): + """Subset of Dify tool stream messages needed for agent observations.""" + + class TextMessage(BaseModel): + text: str + + class JsonMessage(BaseModel): + json_object: dict[str, object] | list[object] + suppress_output: bool = False + + class BlobMessage(BaseModel): + blob: bytes + + class BlobChunkMessage(BaseModel): + id: str + sequence: int + total_length: int + blob: bytes + end: bool + + class FileMessage(BaseModel): + file_marker: str = "file_marker" + + @model_validator(mode="before") + @classmethod + def validate_file_marker(cls, values: object) -> object: + if isinstance(values, dict) and "file_marker" not in values: + raise ValueError("Invalid FileMessage: missing file_marker") + return values + + class VariableMessage(BaseModel): + variable_name: str + variable_value: object + stream: bool = False + + class LogMessage(BaseModel): + id: str + label: str + parent_id: str | None = None + error: str | None = None + status: str + data: Mapping[str, object] = Field(default_factory=dict) + metadata: Mapping[str, object] = Field(default_factory=dict) + + class MessageType(StrEnum): + TEXT = "text" + IMAGE = "image" + LINK = "link" + BLOB = "blob" + JSON = "json" + IMAGE_LINK = "image_link" + BINARY_LINK = "binary_link" + VARIABLE = "variable" + FILE = "file" + LOG = "log" + BLOB_CHUNK = "blob_chunk" + + type: MessageType = MessageType.TEXT + message: ( + TextMessage | JsonMessage | BlobChunkMessage | BlobMessage | LogMessage | FileMessage | VariableMessage | None + ) + meta: dict[str, object] | None = None + + @field_validator("message", mode="before") + @classmethod + def decode_message(cls, value: object, info: ValidationInfo) -> object: + if isinstance(value, dict) and "blob" in value: + try: + value = {**value, "blob": base64.b64decode(value["blob"])} + except Exception: + return value + + msg_type = info.data.get("type") if isinstance(info.data, dict) else None + if msg_type == cls.MessageType.JSON and isinstance(value, dict) and "json_object" not in value: + return {"json_object": value} + if msg_type == cls.MessageType.FILE and isinstance(value, dict): + return {"file_marker": value.get("file_marker", "file_marker")} + return value + + +class DifyPluginToolClientError(Exception): + """Raised when the plugin daemon rejects a tool-layer request.""" + + error_type: str | None + status_code: int | None + + def __init__(self, message: str, *, error_type: str | None = None, status_code: int | None = None) -> None: + super().__init__(message) + self.error_type = error_type + self.status_code = status_code + + +@dataclass(slots=True) +class DifyPluginDaemonToolClient: + """HTTP wrapper for the invoke-only plugin-daemon tool boundary. + + Callers provide business-level provider/tool/credential data per invocation, + while this client supplies daemon transport identity from shared runtime + context: tenant path segment, daemon API key, plugin-specific ``X-Plugin-ID`` + header, and optional top-level ``user_id``. + """ + + plugin_daemon_url: str + plugin_daemon_api_key: str + tenant_id: str + plugin_id: str + user_id: str | None + http_client: httpx.AsyncClient = field(repr=False) + + def __post_init__(self) -> None: + self.plugin_daemon_url = self.plugin_daemon_url.rstrip("/") + + async def invoke( + self, + *, + provider: str, + tool_name: str, + credential_type: DifyPluginToolCredentialType, + credentials: dict[str, object], + tool_parameters: Mapping[str, object], + ) -> list[DifyPluginToolInvokeMessage]: + """Invoke a plugin tool and collect its observation stream.""" + raw_messages = [ + item + async for item in self._iter_stream_response( + path=f"plugin/{self.tenant_id}/dispatch/tool/invoke", + request_data={ + "provider": provider, + "tool": tool_name, + "credentials": credentials, + "credential_type": credential_type, + "tool_parameters": dict(tool_parameters), + }, + response_model=DifyPluginToolInvokeMessage, + ) + ] + return merge_blob_chunks(raw_messages) + + async def _iter_stream_response[T: BaseModel]( + self, + *, + path: str, + request_data: Mapping[str, object], + response_model: type[T], + ) -> AsyncIterator[T]: + """Send one daemon stream request and yield typed items. + + The daemon expects the actual invoke payload nested under ``data``. When + the shared plugin context included ``user_id``, it is forwarded as a + top-level peer to ``data`` so daemon-side auditing and credential logic + can attribute the request to the end user. + """ + payload: dict[str, object] = {"data": to_plugin_daemon_jsonable(dict(request_data))} + if self.user_id is not None: + payload["user_id"] = self.user_id + + url = f"{self.plugin_daemon_url}/{path}" + async with self.http_client.stream("POST", url, headers=self._headers(), json=payload) as response: + if response.is_error: + body = (await response.aread()).decode("utf-8", errors="replace") + error = decode_plugin_daemon_error_payload(body) + if error is not None: + resolved_error = unwrap_plugin_daemon_error( + error_type=error["error_type"], + message=error["message"], + ) + _raise_tool_daemon_error( + error_type=resolved_error["error_type"], + message=resolved_error["message"], + status_code=response.status_code, + ) + raise DifyPluginToolClientError( + body or "Plugin daemon stream request failed.", status_code=response.status_code + ) + + async for raw_line in response.aiter_lines(): + line = raw_line.strip() + if not line: + continue + if line.startswith("data:"): + line = line[5:].strip() + + wrapped = PluginDaemonBasicResponse.model_validate_json(line) + if wrapped.code != 0: + error = decode_plugin_daemon_error_payload(wrapped.message) + if error is not None: + resolved_error = unwrap_plugin_daemon_error( + error_type=error["error_type"], + message=error["message"], + ) + _raise_tool_daemon_error( + error_type=resolved_error["error_type"], + message=resolved_error["message"], + ) + raise DifyPluginToolClientError(wrapped.message or "Plugin daemon returned an error stream item.") + if wrapped.data is None: + raise DifyPluginToolClientError("Plugin daemon returned an empty stream item.") + yield response_model.model_validate(wrapped.data) + + def _headers(self) -> dict[str, str]: + """Build required plugin-daemon transport headers for tool invocation.""" + return { + "X-Api-Key": self.plugin_daemon_api_key, + "X-Plugin-ID": self.plugin_id, + "Content-Type": "application/json", + } + + +def merge_blob_chunks( + response: list[DifyPluginToolInvokeMessage], + *, + max_file_size: int = 30 * 1024 * 1024, + max_chunk_size: int = 8192, +) -> list[DifyPluginToolInvokeMessage]: + """Merge streamed blob chunks into complete blob messages. + + This mirrors Dify API's plugin-daemon chunk-merging behavior before the + higher-level observation conversion logic sees tool stream messages. + """ + files: dict[str, FileChunk] = {} + merged_messages: list[DifyPluginToolInvokeMessage] = [] + + for resp in response: + if resp.type is DifyPluginToolInvokeMessage.MessageType.BLOB_CHUNK: + if not isinstance(resp.message, DifyPluginToolInvokeMessage.BlobChunkMessage): + raise TypeError("Blob chunk responses must carry BlobChunkMessage payloads.") + + chunk_id = resp.message.id + total_length = resp.message.total_length + blob_data = resp.message.blob + is_end = resp.message.end + + if chunk_id not in files: + files[chunk_id] = FileChunk(total_length) + + if files[chunk_id].bytes_written + len(blob_data) > max_file_size: + del files[chunk_id] + raise ValueError(f"File is too large which reached the limit of {max_file_size / 1024 / 1024}MB") + if len(blob_data) > max_chunk_size: + raise ValueError(f"File chunk is too large which reached the limit of {max_chunk_size / 1024}KB") + + files[chunk_id].data[files[chunk_id].bytes_written : files[chunk_id].bytes_written + len(blob_data)] = ( + blob_data + ) + files[chunk_id].bytes_written += len(blob_data) + + if is_end: + merged_messages.append( + DifyPluginToolInvokeMessage( + type=DifyPluginToolInvokeMessage.MessageType.BLOB, + message=DifyPluginToolInvokeMessage.BlobMessage( + blob=bytes(files[chunk_id].data[: files[chunk_id].bytes_written]) + ), + meta=resp.meta, + ) + ) + del files[chunk_id] + else: + merged_messages.append(resp) + + return merged_messages + + +def _raise_tool_daemon_error( + *, + error_type: str, + message: str, + status_code: int | None = None, +) -> None: + raise DifyPluginToolClientError(message, error_type=error_type, status_code=status_code) + + +__all__ = [ + "DifyPluginDaemonToolClient", + "DifyPluginToolClientError", + "DifyPluginToolCredentialType", + "DifyPluginToolInvokeMessage", + "merge_blob_chunks", +] diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py b/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py new file mode 100644 index 0000000000..5ed4a5ea33 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py @@ -0,0 +1,341 @@ +"""Dify plugin tools layer for agent-accessible plugin tools. + +This layer consumes API-prepared plugin tool declarations. The API side is +responsible for resolving daemon declarations, applying runtime-parameter +overrides, and producing the clean LLM-facing JSON schema. At run time the layer +only validates hidden/manual inputs, prepares invocation arguments, and maps +daemon responses into agent-friendly observations. + +Like the LLM layer, this layer never owns live HTTP clients. The runtime passes +the FastAPI lifespan-owned shared client into ``get_tools`` so the layer can +build Pydantic AI tool adapters on demand. +""" + +from __future__ import annotations + +from copy import deepcopy +import json +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import ClassVar + +import httpx +from pydantic_ai import RunContext, Tool +from pydantic_ai.tools import ToolDefinition +from typing_extensions import Self, override + +from agenton.layers import LayerDeps, PlainLayer +from dify_agent.layers.dify_plugin.configs import ( + DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, + DifyPluginToolConfig, + DifyPluginToolParameter, + DifyPluginToolParameterForm, + DifyPluginToolParameterType, + DifyPluginToolsLayerConfig, +) +from dify_agent.layers.dify_plugin.tool_client import ( + DifyPluginDaemonToolClient, + DifyPluginToolClientError, + DifyPluginToolInvokeMessage, +) +from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer + + +# Plugin tools intentionally do not expose a per-tool strictness override in the +# public config. The API supplies already-prepared schemas, but Dify Agent always +# registers those tools in loose mode so daemon tool invocation stays tolerant of +# plugin schema differences and older API-prepared payloads. +PLUGIN_TOOL_STRICT = False + + +class DifyPluginToolsDeps(LayerDeps): + """Dependencies required by ``DifyPluginToolsLayer``.""" + + execution_context: DifyExecutionContextLayer # pyright: ignore[reportUninitializedInstanceVariable] + + +@dataclass(slots=True) +class DifyPluginToolsLayer(PlainLayer[DifyPluginToolsDeps, DifyPluginToolsLayerConfig]): + """Layer that resolves Dify plugin tools into Pydantic AI tools.""" + + type_id: ClassVar[str] = DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID + + config: DifyPluginToolsLayerConfig + + @classmethod + @override + def from_config(cls, config: DifyPluginToolsLayerConfig) -> Self: + """Create the tools layer from validated public config.""" + return cls(config=DifyPluginToolsLayerConfig.model_validate(config)) + + async def get_tools(self, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: + """Build Pydantic AI tool adapters from prepared plugin tool config.""" + tool_clients: dict[str, DifyPluginDaemonToolClient] = {} + tools: list[Tool[object]] = [] + + for tool_config in self.config.tools: + client = tool_clients.get(tool_config.plugin_id) + if client is None: + client = self.deps.execution_context.create_tool_client( + plugin_id=tool_config.plugin_id, + http_client=http_client, + ) + tool_clients[tool_config.plugin_id] = client + effective_parameters = [parameter.model_copy(deep=True) for parameter in tool_config.parameters] + _validate_required_hidden_parameters(tool_config, effective_parameters) + + tools.append( + _build_pydantic_ai_tool( + client=client, + tool_config=tool_config, + effective_parameters=effective_parameters, + ) + ) + + return tools + + +def _validate_required_hidden_parameters( + tool_config: DifyPluginToolConfig, + effective_parameters: Sequence[DifyPluginToolParameter], +) -> None: + missing_names = [ + parameter.name + for parameter in effective_parameters + if parameter.form is not DifyPluginToolParameterForm.LLM + and parameter.required + and parameter.default is None + and parameter.name not in tool_config.runtime_parameters + ] + if missing_names: + names = ", ".join(sorted(missing_names)) + raise ValueError(f"Tool '{tool_config.tool_name}' requires non-LLM runtime_parameters for: {names}.") + + +def _build_pydantic_ai_tool( + *, + client: DifyPluginDaemonToolClient, + tool_config: DifyPluginToolConfig, + effective_parameters: Sequence[DifyPluginToolParameter], +) -> Tool[object]: + tool_name = tool_config.name or tool_config.tool_name + tool_description = tool_config.description or tool_name + tool_schema = deepcopy(tool_config.parameters_json_schema) + + async def invoke_tool(_ctx: RunContext[object], **tool_arguments: object) -> str: + try: + merged_arguments = _prepare_tool_arguments(effective_parameters, tool_config, tool_arguments) + messages = await client.invoke( + provider=tool_config.provider, + tool_name=tool_config.tool_name, + credential_type=tool_config.credential_type, + credentials=dict(tool_config.credentials), + tool_parameters=merged_arguments, + ) + return _convert_tool_response_to_text(messages) + except DifyPluginToolClientError as exc: + return _tool_error_text(tool_name=tool_name, error=exc) + except ValueError as exc: + return f"tool parameters validation error: {exc}, please check your tool parameters" + + async def prepare_tool_definition(_ctx: RunContext[object], tool_def: ToolDefinition) -> ToolDefinition: + return ToolDefinition( + name=tool_def.name, + description=tool_def.description, + parameters_json_schema=tool_schema, + strict=PLUGIN_TOOL_STRICT, + sequential=tool_def.sequential, + metadata=tool_def.metadata, + timeout=tool_def.timeout, + defer_loading=tool_def.defer_loading, + kind=tool_def.kind, + return_schema=tool_def.return_schema, + include_return_schema=tool_def.include_return_schema, + ) + + return Tool( + invoke_tool, + takes_ctx=True, + name=tool_name, + description=tool_description, + prepare=prepare_tool_definition, + ) + + +def _prepare_tool_arguments( + effective_parameters: Sequence[DifyPluginToolParameter], + tool_config: DifyPluginToolConfig, + tool_arguments: Mapping[str, object], +) -> dict[str, object]: + """Build the daemon invocation payload from prepared config + model args. + + Argument precedence intentionally mirrors the old Dify tool runtime contract: + + 1. start from config-supplied ``runtime_parameters`` for hidden/manual inputs; + 2. let model-supplied tool arguments override same-named entries; + 3. if neither provided a value, fall back to the prepared parameter default; + 4. if a required parameter still has no value, raise validation error. + + Only parameters declared in ``effective_parameters`` are type-cast here; + extra merged keys are passed through unchanged for forward compatibility with + prepared config that may contain additional daemon inputs. + """ + merged_arguments: dict[str, object] = dict(tool_config.runtime_parameters) + merged_arguments.update(tool_arguments) + prepared_arguments: dict[str, object] = {} + + for parameter in effective_parameters: + if parameter.name in merged_arguments: + value = merged_arguments[parameter.name] + elif parameter.default is not None: + value = parameter.default + elif parameter.required: + raise ValueError(f"tool parameter {parameter.name} not found in tool config") + else: + continue + prepared_arguments[parameter.name] = _cast_tool_parameter_value(parameter.type, value) + + for key, value in merged_arguments.items(): + prepared_arguments.setdefault(key, value) + return prepared_arguments + + +def _cast_tool_parameter_value(parameter_type: DifyPluginToolParameterType, value: object) -> object: + """Cast prepared tool argument values into daemon-facing wire shapes. + + The API side prepares declaration metadata, but the actual invocation payload + still needs to match Dify plugin-daemon expectations. This helper keeps the + runtime-side coercion rules for common scalar, collection, file, and selector + parameter types so model-supplied JSON values and config-supplied hidden + inputs are normalized before transport. + """ + match parameter_type: + case ( + DifyPluginToolParameterType.STRING + | DifyPluginToolParameterType.SECRET_INPUT + | DifyPluginToolParameterType.SELECT + | DifyPluginToolParameterType.CHECKBOX + | DifyPluginToolParameterType.DYNAMIC_SELECT + ): + return "" if value is None else value if isinstance(value, str) else str(value) + case DifyPluginToolParameterType.BOOLEAN: + if value is None: + return False + if isinstance(value, str): + lowered = value.lower() + if lowered in {"true", "yes", "y", "1"}: + return True + if lowered in {"false", "no", "n", "0"}: + return False + return value if isinstance(value, bool) else bool(value) + case DifyPluginToolParameterType.NUMBER: + if isinstance(value, int | float): + return value + if isinstance(value, str) and value: + return float(value) if "." in value else int(value) + return value + case DifyPluginToolParameterType.SYSTEM_FILES | DifyPluginToolParameterType.FILES: + return value if isinstance(value, list) else [value] + case DifyPluginToolParameterType.FILE: + if isinstance(value, list): + if len(value) != 1: + raise ValueError("This parameter only accepts one file but got multiple files while invoking.") + return value[0] + return value + case DifyPluginToolParameterType.MODEL_SELECTOR | DifyPluginToolParameterType.APP_SELECTOR: + if not isinstance(value, dict): + raise ValueError("The selector must be a dictionary.") + return value + case DifyPluginToolParameterType.ANY: + if value is not None and not isinstance(value, dict | list | str | int | float | bool): + raise ValueError("The var selector must be a string, dictionary, list or number.") + return value + case DifyPluginToolParameterType.ARRAY: + if isinstance(value, list): + return value + if isinstance(value, str): + try: + parsed_value = json.loads(value) + except json.JSONDecodeError: + return [value] + if isinstance(parsed_value, list): + return parsed_value + return [value] + case DifyPluginToolParameterType.OBJECT: + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed_value = json.loads(value) + except json.JSONDecodeError: + return {} + if isinstance(parsed_value, dict): + return parsed_value + return {} + + raise AssertionError(f"Unsupported tool parameter type: {parameter_type}") + + +def _tool_error_text(*, tool_name: str, error: DifyPluginToolClientError) -> str: + """Map expected daemon/tool failures into agent-visible observation text. + + Only known plugin-daemon rejection categories should be softened into tool + observations. Unexpected local bugs are intentionally not handled here and + should propagate so tests and callers notice the regression. + """ + error_type = error.error_type or "" + if any(token in error_type for token in ("Credential", "Authorization", "Unauthorized")): + return "Please check your tool provider credentials" + if any(token in error_type for token in ("ToolNotFound", "ProviderNotFound")): + return f"there is not a tool named {tool_name}" + if error.status_code == 400 or any(token in error_type for token in ("BadRequest", "Validate", "Validation")): + return f"tool parameters validation error: {error}, please check your tool parameters" + return f"tool invoke error: {error}" + + +def _convert_tool_response_to_text(tool_response: Sequence[DifyPluginToolInvokeMessage]) -> str: + """Convert daemon stream messages into the plain-text tool observation. + + This preserves the user-facing semantics Dify's agent tool runtime relies on: + text is appended directly, links/images become user-check instructions, JSON + output is included unless explicitly suppressed, variable messages stay + internal, and everything else falls back to ``str(message)``. JSON fragments + are deduplicated against existing text so mixed text/JSON streams do not + repeat the same content unnecessarily. + """ + parts: list[str] = [] + json_parts: list[str] = [] + + for response in tool_response: + if response.type is DifyPluginToolInvokeMessage.MessageType.TEXT: + text_message = response.message + if isinstance(text_message, DifyPluginToolInvokeMessage.TextMessage): + parts.append(text_message.text) + elif response.type is DifyPluginToolInvokeMessage.MessageType.LINK: + link_message = response.message + if isinstance(link_message, DifyPluginToolInvokeMessage.TextMessage): + parts.append(f"result link: {link_message.text}. please tell user to check it.") + elif response.type in { + DifyPluginToolInvokeMessage.MessageType.IMAGE_LINK, + DifyPluginToolInvokeMessage.MessageType.IMAGE, + }: + parts.append( + "image has been created and sent to user already, " + "you do not need to create it, just tell the user to check it now." + ) + elif response.type is DifyPluginToolInvokeMessage.MessageType.JSON: + json_message = response.message + if isinstance(json_message, DifyPluginToolInvokeMessage.JsonMessage) and not json_message.suppress_output: + json_parts.append(json.dumps(json_message.json_object, ensure_ascii=False, default=str)) + elif response.type is DifyPluginToolInvokeMessage.MessageType.VARIABLE: + continue + else: + parts.append(str(response.message)) + + if json_parts: + existing_parts = set(parts) + parts.extend(part for part in json_parts if part not in existing_parts) + return "".join(parts) + + +__all__ = ["DifyPluginToolsDeps", "DifyPluginToolsLayer"] diff --git a/dify-agent/src/dify_agent/layers/execution_context/__init__.py b/dify-agent/src/dify_agent/layers/execution_context/__init__.py new file mode 100644 index 0000000000..daf67ef7db --- /dev/null +++ b/dify-agent/src/dify_agent/layers/execution_context/__init__.py @@ -0,0 +1,18 @@ +"""Client-safe exports for the Dify execution-context layer DTOs. + +Implementation layers live in sibling modules and require server-side runtime +dependencies. Keep this package root import-safe for client code that only +needs to build run requests. +""" + +from dify_agent.layers.execution_context.configs import ( + DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + DifyExecutionContextInvokeFrom, + DifyExecutionContextLayerConfig, +) + +__all__ = [ + "DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID", + "DifyExecutionContextInvokeFrom", + "DifyExecutionContextLayerConfig", +] diff --git a/dify-agent/src/dify_agent/layers/execution_context/configs.py b/dify-agent/src/dify_agent/layers/execution_context/configs.py new file mode 100644 index 0000000000..e5eedbba3c --- /dev/null +++ b/dify-agent/src/dify_agent/layers/execution_context/configs.py @@ -0,0 +1,50 @@ +"""Client-safe DTOs for the Dify execution-context Agenton layer. + +This layer carries Dify-owned execution identifiers plus the tenant/user daemon +transport context shared by plugin-backed business layers. The identifiers are +for observability and product correlation only; callers must not treat them as +authorization proof. Server-only plugin-daemon settings are injected by the +runtime provider factory and therefore do not appear in this public schema. +""" + +from typing import ClassVar, Final, Literal, TypeAlias + +from pydantic import ConfigDict + +from agenton.layers import LayerConfig + + +DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID: Final[str] = "dify.execution_context" +DifyExecutionContextInvokeFrom: TypeAlias = Literal[ + "workflow_run", + "single_step", + "agent_app", + "babysit", + "fasten", +] + + +class DifyExecutionContextLayerConfig(LayerConfig): + """Public config for Dify execution identity and daemon transport context.""" + + tenant_id: str + user_id: str | None = None + app_id: str | None = None + workflow_id: str | None = None + workflow_run_id: str | None = None + node_id: str | None = None + node_execution_id: str | None = None + conversation_id: str | None = None + agent_id: str | None = None + agent_config_version_id: str | None = None + invoke_from: DifyExecutionContextInvokeFrom + trace_id: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True) + + +__all__ = [ + "DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID", + "DifyExecutionContextInvokeFrom", + "DifyExecutionContextLayerConfig", +] diff --git a/dify-agent/src/dify_agent/layers/execution_context/layer.py b/dify-agent/src/dify_agent/layers/execution_context/layer.py new file mode 100644 index 0000000000..06ef41ecf4 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/execution_context/layer.py @@ -0,0 +1,95 @@ +"""Runtime Dify execution-context layer. + +The public config carries Dify-owned execution identifiers plus the tenant/user +daemon context needed by plugin-backed business layers. Server-only daemon URL +and API key are injected by the provider factory. The layer is intentionally +config/settings-only under Agenton's state-only core: it does not open, cache, +close, or snapshot HTTP clients, and its lifecycle hooks remain the inherited +no-op hooks. Runtime code passes the FastAPI lifespan-owned shared +``httpx.AsyncClient`` into ``create_daemon_provider`` or ``create_tool_client`` +for each invocation. +""" + +from dataclasses import dataclass +from typing import ClassVar + +import httpx +from typing_extensions import Self, override + +from agenton.layers import EmptyRuntimeState, NoLayerDeps, PlainLayer +from dify_agent.adapters.llm import DifyPluginDaemonProvider +from dify_agent.layers.dify_plugin.tool_client import DifyPluginDaemonToolClient +from dify_agent.layers.execution_context.configs import ( + DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + DifyExecutionContextLayerConfig, +) + + +@dataclass(slots=True) +class DifyExecutionContextLayer(PlainLayer[NoLayerDeps, DifyExecutionContextLayerConfig, EmptyRuntimeState]): + """Layer that carries Dify execution context without owning live resources.""" + + type_id: ClassVar[str] = DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID + + config: DifyExecutionContextLayerConfig + daemon_url: str + daemon_api_key: str + + @classmethod + @override + def from_config(cls, config: DifyExecutionContextLayerConfig) -> Self: + """Reject construction without server-injected daemon settings.""" + del config + raise TypeError( + "DifyExecutionContextLayer requires server-side daemon settings and must use a provider factory." + ) + + @classmethod + def from_config_with_settings( + cls, + config: DifyExecutionContextLayerConfig, + *, + daemon_url: str, + daemon_api_key: str, + ) -> Self: + """Create the layer from public config plus server-only daemon settings.""" + return cls(config=config, daemon_url=daemon_url, daemon_api_key=daemon_api_key) + + def create_daemon_provider(self, *, plugin_id: str, http_client: httpx.AsyncClient) -> DifyPluginDaemonProvider: + """Return a daemon provider backed by the shared plugin daemon client. + + Raises: + RuntimeError: if ``http_client`` has already been closed. + """ + if http_client.is_closed: + raise RuntimeError( + "DifyExecutionContextLayer.create_daemon_provider() requires an open shared HTTP client." + ) + return DifyPluginDaemonProvider( + tenant_id=self.config.tenant_id, + plugin_id=plugin_id, + plugin_daemon_url=self.daemon_url, + plugin_daemon_api_key=self.daemon_api_key, + user_id=self.config.user_id, + http_client=http_client, + ) + + def create_tool_client(self, *, plugin_id: str, http_client: httpx.AsyncClient) -> DifyPluginDaemonToolClient: + """Return a plugin-daemon tool client backed by the shared HTTP client. + + Raises: + RuntimeError: if ``http_client`` has already been closed. + """ + if http_client.is_closed: + raise RuntimeError("DifyExecutionContextLayer.create_tool_client() requires an open shared HTTP client.") + return DifyPluginDaemonToolClient( + tenant_id=self.config.tenant_id, + plugin_id=plugin_id, + plugin_daemon_url=self.daemon_url, + plugin_daemon_api_key=self.daemon_api_key, + user_id=self.config.user_id, + http_client=http_client, + ) + + +__all__ = ["DifyExecutionContextLayer"] diff --git a/dify-agent/src/dify_agent/plugin_daemon_transport.py b/dify-agent/src/dify_agent/plugin_daemon_transport.py new file mode 100644 index 0000000000..dc88c3f01e --- /dev/null +++ b/dify-agent/src/dify_agent/plugin_daemon_transport.py @@ -0,0 +1,72 @@ +"""Shared plugin-daemon transport helpers. + +These helpers define the common request-payload and nested-error semantics used +by Dify Agent's LLM and tools daemon clients so the two transport adapters do +not drift when the daemon protocol evolves. +""" + +from __future__ import annotations + +import json +from typing import TypedDict + +from pydantic import BaseModel + + +class PluginDaemonErrorPayload(TypedDict): + """Decoded plugin-daemon error payload.""" + + error_type: str + message: str + + +def to_plugin_daemon_jsonable(value: object) -> object: + """Convert nested request data into JSON-safe daemon payload values.""" + if isinstance(value, BaseModel): + return value.model_dump(mode="json") + if isinstance(value, dict): + return {key: to_plugin_daemon_jsonable(item) for key, item in value.items()} + if isinstance(value, list | tuple): + return [to_plugin_daemon_jsonable(item) for item in value] + return value + + +def decode_plugin_daemon_error_payload(raw_message: str) -> PluginDaemonErrorPayload | None: + """Decode one plugin-daemon JSON error payload if present.""" + try: + parsed = json.loads(raw_message) + except json.JSONDecodeError: + return None + + if not isinstance(parsed, dict): + return None + + error_type = parsed.get("error_type") + message = parsed.get("message") + if not isinstance(error_type, str) or not isinstance(message, str): + return None + return {"error_type": error_type, "message": message} + + +def unwrap_plugin_daemon_error( + *, + error_type: str, + message: str, +) -> PluginDaemonErrorPayload: + """Unwrap nested ``PluginInvokeError`` payloads to their effective error.""" + if error_type == "PluginInvokeError": + nested_error = decode_plugin_daemon_error_payload(message) + if nested_error is not None: + return unwrap_plugin_daemon_error( + error_type=nested_error["error_type"], + message=nested_error["message"], + ) + return {"error_type": error_type, "message": message} + + +__all__ = [ + "PluginDaemonErrorPayload", + "decode_plugin_daemon_error_payload", + "to_plugin_daemon_jsonable", + "unwrap_plugin_daemon_error", +] diff --git a/dify-agent/src/dify_agent/protocol/__init__.py b/dify-agent/src/dify_agent/protocol/__init__.py index d1daba54d6..2e3c959548 100644 --- a/dify-agent/src/dify_agent/protocol/__init__.py +++ b/dify-agent/src/dify_agent/protocol/__init__.py @@ -11,8 +11,6 @@ from .schemas import ( CreateRunRequest, CreateRunResponse, EmptyRunEventData, - ExecutionContext, - InvokeFrom, LayerExitSignals, PydanticAIStreamRunEvent, RunCancelledEvent, @@ -46,8 +44,6 @@ __all__ = [ "DIFY_AGENT_MODEL_LAYER_ID", "DIFY_AGENT_OUTPUT_LAYER_ID", "EmptyRunEventData", - "ExecutionContext", - "InvokeFrom", "LayerExitSignals", "PydanticAIStreamRunEvent", "RUN_EVENT_ADAPTER", diff --git a/dify-agent/src/dify_agent/protocol/schemas.py b/dify-agent/src/dify_agent/protocol/schemas.py index 6990ce7f57..9a989976c7 100644 --- a/dify-agent/src/dify_agent/protocol/schemas.py +++ b/dify-agent/src/dify_agent/protocol/schemas.py @@ -47,7 +47,6 @@ DIFY_AGENT_HISTORY_LAYER_ID: Final[str] = "history" DIFY_AGENT_OUTPUT_LAYER_ID: Final[str] = "output" RunStatus = Literal["running", "paused", "succeeded", "failed", "cancelled"] RunPurpose = Literal["workflow_node", "single_step", "agent_app", "babysit", "fasten_preview"] -InvokeFrom = Literal["workflow_run", "single_step", "agent_app", "babysit", "fasten"] RunEventType = Literal[ "run_started", "pydantic_ai_event", @@ -106,29 +105,6 @@ class RunComposition(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") -class ExecutionContext(BaseModel): - """Dify-owned execution identifiers attached to one Agent backend run. - - The Agent backend stores and replays this context for observability and - product correlation only. It must not use these identifiers as authorization - proof; API backend remains responsible for tenant and user access checks. - """ - - tenant_id: str - app_id: str | None = None - workflow_id: str | None = None - workflow_run_id: str | None = None - node_id: str | None = None - node_execution_id: str | None = None - conversation_id: str | None = None - agent_id: str | None = None - agent_config_version_id: str | None = None - invoke_from: InvokeFrom - trace_id: str | None = None - - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - class CreateRunRequest(BaseModel): """Request body for creating one async agent run. @@ -142,11 +118,13 @@ class CreateRunRequest(BaseModel): explicitly request delete for one or more layers. Session snapshots do not preserve output-layer config, so resume requests that rely on structured output must include the same ``output`` layer in ``composition.layers[]`` to - keep snapshot compatibility and rebuild the output schema. + keep snapshot compatibility and rebuild the output schema. Dify tenant, + user, and run-correlation identifiers must be submitted through a + ``dify.execution_context`` entry in ``composition.layers[]``; there is no + parallel top-level ``execution_context`` request field. """ composition: RunComposition - execution_context: ExecutionContext | None = None purpose: RunPurpose = "workflow_node" idempotency_key: str | None = None metadata: dict[str, JsonValue] = Field(default_factory=dict) @@ -356,8 +334,6 @@ __all__ = [ "DIFY_AGENT_MODEL_LAYER_ID", "DIFY_AGENT_OUTPUT_LAYER_ID", "EmptyRunEventData", - "ExecutionContext", - "InvokeFrom", "LayerExitSignals", "PydanticAIStreamRunEvent", "RUN_EVENT_ADAPTER", diff --git a/dify-agent/src/dify_agent/runtime/compositor_factory.py b/dify-agent/src/dify_agent/runtime/compositor_factory.py index 8750dbc71d..f3cc3b37b3 100644 --- a/dify-agent/src/dify_agent/runtime/compositor_factory.py +++ b/dify-agent/src/dify_agent/runtime/compositor_factory.py @@ -2,12 +2,18 @@ Only explicitly allowed provider type ids are constructible here. The default provider set contains prompt layers, the optional pydantic-ai history layer, the -state-free Dify structured output layer, plus Dify plugin LLM layers. Public -DTOs provide tenant/plugin/model data, while server-only plugin daemon settings -are injected through the provider factory for ``DifyPluginLayer``. The resulting -``Compositor`` remains Agenton state-only: live resources such as the plugin -daemon HTTP client are supplied later by the runtime and never enter providers, -layers, or session snapshots. +state-free Dify structured output layer, the Dify execution-context layer, and +the Dify plugin business-layer family: + +- ``dify.execution_context`` for shared tenant/user/run daemon context, +- ``dify.plugin.llm`` for plugin-backed model selection, and +- ``dify.plugin.tools`` for prepared plugin tool exposure. + +Public DTOs provide Dify context plus plugin/model/tool data, while server-only +plugin daemon settings are injected through the provider factory for +``DifyExecutionContextLayer``. The resulting ``Compositor`` remains Agenton +state-only: live resources such as the plugin daemon HTTP client are supplied +later by the runtime and never enter providers, layers, or session snapshots. """ from collections.abc import Mapping, Sequence @@ -20,9 +26,10 @@ from agenton.layers.types import AllPromptTypes, AllToolTypes, AllUserPromptType from agenton_collections.layers.pydantic_ai import PydanticAIHistoryLayer from agenton_collections.layers.plain.basic import PromptLayer from agenton_collections.transformers.pydantic_ai import PYDANTIC_AI_TRANSFORMERS -from dify_agent.layers.dify_plugin.configs import DifyPluginLayerConfig from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer -from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer +from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer +from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig +from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer from dify_agent.layers.output.output_layer import DifyOutputLayer @@ -40,14 +47,15 @@ def create_default_layer_providers( LayerProvider.from_layer_type(PydanticAIHistoryLayer), LayerProvider.from_layer_type(DifyOutputLayer), LayerProvider.from_factory( - layer_type=DifyPluginLayer, - create=lambda config: DifyPluginLayer.from_config_with_settings( - DifyPluginLayerConfig.model_validate(config), + layer_type=DifyExecutionContextLayer, + create=lambda config: DifyExecutionContextLayer.from_config_with_settings( + DifyExecutionContextLayerConfig.model_validate(config), daemon_url=plugin_daemon_url, daemon_api_key=plugin_daemon_api_key, ), ), LayerProvider.from_layer_type(DifyPluginLLMLayer), + LayerProvider.from_layer_type(DifyPluginToolsLayer), ) diff --git a/dify-agent/src/dify_agent/runtime/run_scheduler.py b/dify-agent/src/dify_agent/runtime/run_scheduler.py index 5d8b229461..9dfc93b846 100644 --- a/dify-agent/src/dify_agent/runtime/run_scheduler.py +++ b/dify-agent/src/dify_agent/runtime/run_scheduler.py @@ -5,12 +5,11 @@ The scheduler is intentionally process-local: it persists a run record, starts a task registry. Redis remains the durable source for status and event streams, but there is no Redis job queue or cross-process handoff. If the process crashes, currently active runs are lost until an external operator marks or retries them. -Create-run validation enters a lightweight Agenton run before persistence so the -same transformed user prompts, temporary system-prompt history assembly, -optional structured output contract, and top-level ``on_exit`` policy used by -execution are checked without relying on removed session/control APIs; Dify's -default layers keep lifecycle hooks side-effect free so this validation does not -open plugin daemon clients. +Create-run requests are accepted once the scheduler is not stopping and storage +can persist the run record. Request-shaped execution failures are left to +``AgentRunRunner`` so bad compositions, ``on_exit`` policies, prompts, +structured-output schemas, or session snapshots become asynchronous +``run_failed`` outcomes instead of synchronous HTTP rejections. """ import asyncio @@ -21,15 +20,10 @@ from typing import Protocol import httpx from agenton.compositor import LayerProviderInput -from dify_agent.protocol.schemas import CreateRunRequest, normalize_composition -from dify_agent.runtime.agenton_validation import is_agenton_enter_validation_runtime_error -from dify_agent.runtime.compositor_factory import build_pydantic_ai_compositor, create_default_layer_providers +from dify_agent.protocol.schemas import CreateRunRequest +from dify_agent.runtime.compositor_factory import create_default_layer_providers from dify_agent.runtime.event_sink import RunEventSink, emit_run_failed -from dify_agent.runtime.history import build_run_message_history, get_history_layer, validate_history_layer_composition -from dify_agent.runtime.layer_exit_signals import apply_layer_exit_signals, validate_layer_exit_signals -from dify_agent.runtime.output_type import resolve_run_output_contract, validate_output_layer_composition from dify_agent.runtime.runner import AgentRunRunner -from dify_agent.runtime.user_prompt_validation import EMPTY_USER_PROMPTS_ERROR, has_non_blank_user_prompt from dify_agent.server.schemas import RunRecord logger = logging.getLogger(__name__) @@ -39,10 +33,6 @@ class SchedulerStoppingError(RuntimeError): """Raised when a create-run request arrives after shutdown has started.""" -class RunRequestValidationError(ValueError): - """Raised when a create-run request cannot produce an executable Agenton run.""" - - class RunStore(RunEventSink, Protocol): """Persistence boundary needed by the scheduler.""" @@ -68,9 +58,8 @@ class RunScheduler: ``active_tasks`` is mutated only on the event loop that calls ``create_run`` and ``shutdown``. The task registry is not durable; it exists so the lifespan hook can wait for in-flight work and mark cancelled runs failed before Redis is - closed. A lock guards the stopping flag, lightweight request validation, run - persistence, and task registration so shutdown cannot begin after a request is - admitted and no validation runs once stopping has been set. + closed. A lock guards the stopping flag, run persistence, and task + registration so shutdown cannot begin after a request is admitted. """ store: RunStore @@ -101,15 +90,16 @@ class RunScheduler: self._lifecycle_lock = asyncio.Lock() async def create_run(self, request: CreateRunRequest) -> RunRecord: - """Validate, persist, and schedule one run in the current process. + """Persist and schedule one run in the current process. The returned record is already ``running``. The background task is removed from ``active_tasks`` when it finishes, regardless of success or failure. + Request-shaped runtime failures are intentionally deferred to the runner so + callers can observe them through the normal event/status stream. """ async with self._lifecycle_lock: if self.stopping: raise SchedulerStoppingError("run scheduler is shutting down") - await validate_run_request(request, layer_providers=self.layer_providers) record = await self.store.create_run() task = asyncio.create_task(self._run_record(record, request), name=f"dify-agent-run-{record.run_id}") self.active_tasks[record.run_id] = task @@ -164,52 +154,4 @@ class RunScheduler: logger.exception("failed to mark cancelled run failed", extra={"run_id": run_id}) -async def validate_run_request( - request: CreateRunRequest, - *, - layer_providers: tuple[LayerProviderInput, ...] | None = None, -) -> None: - """Validate create-run semantics that require an entered Agenton run. - - This boundary rejects unsupported output/history-layer graph shapes, unknown - ``on_exit`` layer ids, effectively empty transformed user prompts, and known - enter-time snapshot lifecycle errors before the scheduler persists a run - record. It also exercises provider config validation, temporary - system-prompt history assembly, structured output contract construction, and - snapshot hydration without touching external services because Dify plugin - daemon clients are owned by the FastAPI lifespan, not Agenton lifecycle - hooks. - """ - resolved_layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers() - entered_run = False - try: - validate_output_layer_composition(request.composition) - validate_history_layer_composition(request.composition) - graph_config, layer_configs = normalize_composition(request.composition) - compositor = build_pydantic_ai_compositor( - graph_config, - providers=resolved_layer_providers, - ) - validate_layer_exit_signals(compositor, request.on_exit) - async with compositor.enter(configs=layer_configs, session_snapshot=request.session_snapshot) as run: - entered_run = True - apply_layer_exit_signals(run, request.on_exit) - history_layer = get_history_layer(run) - _ = await build_run_message_history( - system_prompts=run.prompts, - stored_history=history_layer.message_history if history_layer is not None else (), - ) - if not has_non_blank_user_prompt(run.user_prompts): - raise RunRequestValidationError(EMPTY_USER_PROMPTS_ERROR) - _ = resolve_run_output_contract(run) - except RunRequestValidationError: - raise - except RuntimeError as exc: - if not entered_run and is_agenton_enter_validation_runtime_error(exc): - raise RunRequestValidationError(str(exc)) from exc - raise - except (KeyError, TypeError, ValueError) as exc: - raise RunRequestValidationError(str(exc)) from exc - - -__all__ = ["RunRequestValidationError", "RunScheduler", "SchedulerStoppingError", "validate_run_request"] +__all__ = ["RunScheduler", "SchedulerStoppingError"] diff --git a/dify-agent/src/dify_agent/runtime/runner.py b/dify-agent/src/dify_agent/runtime/runner.py index b1e1758928..11e99bb838 100644 --- a/dify-agent/src/dify_agent/runtime/runner.py +++ b/dify-agent/src/dify_agent/runtime/runner.py @@ -21,14 +21,17 @@ snapshot; there are no separate output or snapshot events to correlate. """ from collections.abc import AsyncIterable -from typing import cast +from collections import Counter +from typing import Any, cast import httpx from pydantic import JsonValue, TypeAdapter from pydantic_ai.messages import AgentStreamEvent from agenton.compositor import CompositorSessionSnapshot, LayerProviderInput +from agenton.layers.types import PydanticAITool from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer +from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer from dify_agent.protocol.schemas import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, normalize_composition from dify_agent.runtime.agent_factory import create_agent, normalize_user_input from dify_agent.runtime.agenton_validation import is_agenton_enter_validation_runtime_error @@ -149,12 +152,13 @@ class AgentRunRunner: ) llm_layer = run.get_layer(DIFY_AGENT_MODEL_LAYER_ID, DifyPluginLLMLayer) model = llm_layer.get_model(http_client=self.plugin_daemon_http_client) + tools = await _resolve_run_tools(run, http_client=self.plugin_daemon_http_client) except (KeyError, TypeError, RuntimeError, ValueError) as exc: raise AgentRunValidationError(str(exc)) from exc agent = create_agent( model, - tools=run.tools, + tools=tools, output_type=output_contract.output_type, ) result = await agent.run( @@ -180,4 +184,27 @@ def _serialize_agent_output(output: object) -> JsonValue: return cast(JsonValue, _AGENT_OUTPUT_ADAPTER.dump_python(output, mode="json")) +async def _resolve_run_tools( + run: Any, + *, + http_client: httpx.AsyncClient, +) -> list[PydanticAITool[object]]: + """Return the static compositor tools plus any Dify plugin runtime tools.""" + resolved_tools = list(cast(list[PydanticAITool[object]], run.tools)) + for slot in run.slots.values(): + layer = slot.layer + if isinstance(layer, DifyPluginToolsLayer): + resolved_tools.extend(await layer.get_tools(http_client=http_client)) + _validate_unique_tool_names(resolved_tools) + return resolved_tools + + +def _validate_unique_tool_names(tools: list[PydanticAITool[object]]) -> None: + """Reject duplicate tool names across static and dynamic tool sources.""" + duplicate_names = sorted(name for name, count in Counter(tool.name for tool in tools).items() if count > 1) + if duplicate_names: + names = ", ".join(duplicate_names) + raise ValueError(f"Agent run requires unique tool names across all layers, got duplicates: {names}.") + + __all__ = ["AgentRunRunner", "AgentRunValidationError"] diff --git a/dify-agent/src/dify_agent/runtime/user_prompt_validation.py b/dify-agent/src/dify_agent/runtime/user_prompt_validation.py index 8e8602c864..c2cda83acc 100644 --- a/dify-agent/src/dify_agent/runtime/user_prompt_validation.py +++ b/dify-agent/src/dify_agent/runtime/user_prompt_validation.py @@ -1,8 +1,8 @@ """Validation for effective user prompts produced by Agenton runs. -Validation happens after safe compositor construction and run entry so scheduler -and runner paths use the same transformed prompts as the actual pydantic-ai -input. Blank string fragments do not count as meaningful input; non-string +Validation happens after safe compositor construction and run entry so runtime +execution uses the same transformed prompts as the actual pydantic-ai input. +Blank string fragments do not count as meaningful input; non-string ``UserContent`` is treated as intentional content because rich media/message parts do not have a universal whitespace representation. """ diff --git a/dify-agent/src/dify_agent/server/routes/runs.py b/dify-agent/src/dify_agent/server/routes/runs.py index a5dff09218..1cbd9d2094 100644 --- a/dify-agent/src/dify_agent/server/routes/runs.py +++ b/dify-agent/src/dify_agent/server/routes/runs.py @@ -1,10 +1,13 @@ """FastAPI routes for asynchronous agent runs. -Controllers translate known validation and shutdown errors into HTTP status codes. -Unexpected scheduler or storage failures are intentionally left for FastAPI's -server-error handling so infrastructure problems are not reported as client input -errors. Created runs are scheduled in the current process and observed through -status polling or SSE replay backed by Redis event streams. +Controllers translate shutdown errors into HTTP status codes. Runtime request +failures are intentionally not pre-mapped here: once a request passes DTO +validation it is accepted for background execution, and bad compositions or +snapshots fail later through normal run events/status. Unexpected scheduler or +storage failures are intentionally left for FastAPI's server-error handling so +infrastructure problems are not reported as client input errors. Created runs +are scheduled in the current process and observed through status polling or SSE +replay backed by Redis event streams. """ from collections.abc import Callable @@ -21,7 +24,7 @@ from dify_agent.protocol.schemas import ( RunEventsResponse, RunStatusResponse, ) -from dify_agent.runtime.run_scheduler import RunRequestValidationError, RunScheduler, SchedulerStoppingError +from dify_agent.runtime.run_scheduler import RunScheduler, SchedulerStoppingError from dify_agent.server.sse import sse_event_stream from dify_agent.storage.redis_run_store import RedisRunStore, RunNotFoundError @@ -46,8 +49,6 @@ def create_runs_router( ) -> CreateRunResponse: try: record = await scheduler.create_run(request) - except RunRequestValidationError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc except SchedulerStoppingError as exc: raise HTTPException(status_code=503, detail="run scheduler is shutting down") from exc return CreateRunResponse(run_id=record.run_id, status=record.status) diff --git a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_configs.py b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_configs.py index f6f84772ba..a3db61a06c 100644 --- a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_configs.py +++ b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_configs.py @@ -1,3 +1,5 @@ +from types import SimpleNamespace + import pytest from pydantic import ValidationError @@ -5,55 +7,54 @@ import dify_agent.layers.dify_plugin as dify_plugin_exports from dify_agent.layers.dify_plugin import ( DifyPluginCredentialValue, DifyPluginLLMLayerConfig, - DifyPluginLayerConfig, + DifyPluginToolCredentialType, + DifyPluginToolConfig, + DifyPluginToolParameter, + DifyPluginToolParameterForm, + DifyPluginToolParameterType, + DifyPluginToolsLayerConfig, + DifyPluginToolValue, ) def test_dify_plugin_package_exports_client_safe_config_symbols_only() -> None: assert dify_plugin_exports.__all__ == [ - "DIFY_PLUGIN_LAYER_TYPE_ID", "DIFY_PLUGIN_LLM_LAYER_TYPE_ID", + "DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID", "DifyPluginCredentialValue", "DifyPluginLLMLayerConfig", - "DifyPluginLayerConfig", + "DifyPluginToolCredentialType", + "DifyPluginToolConfig", + "DifyPluginToolOption", + "DifyPluginToolParameter", + "DifyPluginToolParameterForm", + "DifyPluginToolParameterType", + "DifyPluginToolsLayerConfig", + "DifyPluginToolValue", ] - assert dify_plugin_exports.DIFY_PLUGIN_LAYER_TYPE_ID == "dify.plugin" assert dify_plugin_exports.DIFY_PLUGIN_LLM_LAYER_TYPE_ID == "dify.plugin.llm" - assert not hasattr(dify_plugin_exports, "DifyPluginLayer") + assert dify_plugin_exports.DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID == "dify.plugin.tools" assert not hasattr(dify_plugin_exports, "DifyPluginLLMLayer") -def test_dify_plugin_layer_config_forbids_runtime_settings() -> None: - config = DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="plugin-1", user_id="user-1") - - assert config.tenant_id == "tenant-1" - assert config.plugin_id == "plugin-1" - assert config.user_id == "user-1" - with pytest.raises(ValidationError): - _ = DifyPluginLayerConfig.model_validate( - { - "tenant_id": "tenant-1", - "plugin_id": "plugin-1", - "daemon_url": "http://daemon", - } - ) - - def test_dify_plugin_llm_config_accepts_scalar_credentials_and_model_settings() -> None: credential: DifyPluginCredentialValue = "secret" config = DifyPluginLLMLayerConfig( + plugin_id="langgenius/openai", model_provider="openai", model="gpt-4o-mini", credentials={"api_key": credential, "enabled": True, "retries": 2, "ratio": 0.5, "empty": None}, model_settings={"temperature": 0.2, "max_tokens": 64}, ) + assert config.plugin_id == "langgenius/openai" assert config.model_provider == "openai" assert config.credentials == {"api_key": "secret", "enabled": True, "retries": 2, "ratio": 0.5, "empty": None} assert config.model_settings == {"temperature": 0.2, "max_tokens": 64} with pytest.raises(ValidationError): _ = DifyPluginLLMLayerConfig.model_validate( { + "plugin_id": "langgenius/openai", "model_provider": "openai", "model": "gpt-4o-mini", "credentials": {"nested": {"not": "allowed"}}, @@ -66,6 +67,154 @@ def test_dify_plugin_llm_config_rejects_old_provider_field() -> None: _ = DifyPluginLLMLayerConfig.model_validate( { "provider": "openai", + "plugin_id": "langgenius/openai", "model": "gpt-4o-mini", } ) + + +def test_dify_plugin_tools_layer_config_accepts_prepared_parameters_and_schema() -> None: + runtime_value: DifyPluginToolValue = {"locale": "en-US", "max_results": 5} + credential_type: DifyPluginToolCredentialType = "api-key" + config = DifyPluginToolsLayerConfig( + tools=[ + DifyPluginToolConfig( + plugin_id="langgenius/tools", + provider="search", + tool_name="web_search", + credential_type=credential_type, + name="search_web", + description="Search the web.", + credentials={"api_key": "secret"}, + runtime_parameters={"settings": runtime_value}, + parameters=[ + DifyPluginToolParameter( + name="query", + type=DifyPluginToolParameterType.STRING, + form=DifyPluginToolParameterForm.LLM, + required=True, + llm_description="Search query", + ) + ], + parameters_json_schema={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + }, + "required": ["query"], + }, + ) + ] + ) + + assert config.tools[0].plugin_id == "langgenius/tools" + assert config.tools[0].provider == "search" + assert config.tools[0].tool_name == "web_search" + assert config.tools[0].credential_type == "api-key" + assert config.tools[0].name == "search_web" + assert config.tools[0].runtime_parameters == {"settings": {"locale": "en-US", "max_results": 5}} + assert config.tools[0].parameters[0].name == "query" + assert config.tools[0].parameters_json_schema["required"] == ["query"] + + +def test_dify_plugin_tool_parameter_accepts_api_tool_parameter_dump_shape() -> None: + parameter = DifyPluginToolParameter.model_validate( + { + "name": "query", + "label": {"en_US": "Query"}, + "placeholder": None, + "human_description": {"en_US": "Visible in UI"}, + "type": "select", + "form": "llm", + "required": True, + "default": "dify", + "llm_description": "Search query", + "input_schema": {"type": "string"}, + "options": [ + { + "value": "dify", + "label": {"en_US": "Dify"}, + } + ], + } + ) + + assert parameter.name == "query" + assert parameter.type is DifyPluginToolParameterType.SELECT + assert parameter.form is DifyPluginToolParameterForm.LLM + assert parameter.required is True + assert parameter.default == "dify" + assert parameter.input_schema == {"type": "string"} + assert [option.value for option in parameter.options] == ["dify"] + + +def test_dify_plugin_tool_parameter_accepts_api_tool_parameter_attributes() -> None: + parameter = DifyPluginToolParameter.model_validate( + SimpleNamespace( + name="language", + label=SimpleNamespace(en_US="Language"), + type="string", + form="form", + required=False, + default="en", + llm_description=None, + input_schema=None, + options=[SimpleNamespace(value="en", label=SimpleNamespace(en_US="English"))], + ) + ) + + assert parameter.name == "language" + assert parameter.type is DifyPluginToolParameterType.STRING + assert parameter.form is DifyPluginToolParameterForm.FORM + assert parameter.default == "en" + assert [option.value for option in parameter.options] == ["en"] + + +def test_dify_plugin_tool_config_rejects_non_json_runtime_parameters() -> None: + with pytest.raises(ValidationError): + _ = DifyPluginToolConfig.model_validate( + { + "plugin_id": "langgenius/tools", + "provider": "search", + "tool_name": "web_search", + "credential_type": "api-key", + "runtime_parameters": {"bad": object()}, + } + ) + + +def test_dify_plugin_tool_config_rejects_non_json_schema_values() -> None: + with pytest.raises(ValidationError): + _ = DifyPluginToolConfig.model_validate( + { + "plugin_id": "langgenius/tools", + "provider": "search", + "tool_name": "web_search", + "credential_type": "api-key", + "parameters_json_schema": {"type": object()}, + } + ) + + +def test_dify_plugin_tool_config_rejects_strict_flag() -> None: + with pytest.raises(ValidationError): + _ = DifyPluginToolConfig.model_validate( + { + "plugin_id": "langgenius/tools", + "provider": "search", + "tool_name": "web_search", + "credential_type": "api-key", + "strict": True, + } + ) + + +def test_dify_plugin_tool_config_requires_explicit_credential_type() -> None: + with pytest.raises(ValidationError): + _ = DifyPluginToolConfig.model_validate( + { + "plugin_id": "langgenius/tools", + "provider": "search", + "tool_name": "web_search", + } + ) diff --git a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py index 78c833d946..515e187ef3 100644 --- a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py +++ b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py @@ -1,26 +1,36 @@ import asyncio +import json import httpx import pytest +from pydantic import JsonValue from agenton.compositor import Compositor, LayerNode, LayerProvider from dify_agent.adapters.llm import DifyLLMAdapterModel from dify_agent.layers.dify_plugin.configs import ( - DIFY_PLUGIN_LAYER_TYPE_ID, DIFY_PLUGIN_LLM_LAYER_TYPE_ID, + DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, DifyPluginLLMLayerConfig, - DifyPluginLayerConfig, + DifyPluginToolConfig, + DifyPluginToolOption, + DifyPluginToolParameter, + DifyPluginToolParameterForm, + DifyPluginToolParameterType, + DifyPluginToolsLayerConfig, ) from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer -from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer +from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig +from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer -def _plugin_config() -> DifyPluginLayerConfig: - return DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai", user_id="user-1") +def _execution_context_config() -> DifyExecutionContextLayerConfig: + return DifyExecutionContextLayerConfig(tenant_id="tenant-1", user_id="user-1", invoke_from="workflow_run") def _llm_config() -> DifyPluginLLMLayerConfig: return DifyPluginLLMLayerConfig( + plugin_id="langgenius/openai", model_provider="openai", model="demo-model", credentials={"api_key": "secret"}, @@ -28,82 +38,192 @@ def _llm_config() -> DifyPluginLLMLayerConfig: ) -def _plugin_layer() -> DifyPluginLayer: - return DifyPluginLayer.from_config_with_settings( - _plugin_config(), - daemon_url="http://plugin-daemon", - daemon_api_key="daemon-secret", +def _tools_config() -> DifyPluginToolsLayerConfig: + return DifyPluginToolsLayerConfig( + tools=[ + DifyPluginToolConfig( + plugin_id="langgenius/tools", + provider="search", + tool_name="web_search", + credential_type="api-key", + description="Search the web.", + credentials={"api_key": "secret"}, + runtime_parameters={"api_version": "2026-01", "auth_scope": "workspace"}, + parameters=_prepared_tool_parameters(), + parameters_json_schema=_prepared_tool_schema(), + ) + ] ) -def _plugin_provider() -> LayerProvider[DifyPluginLayer]: +def _missing_hidden_parameter_tools_config() -> DifyPluginToolsLayerConfig: + return DifyPluginToolsLayerConfig( + tools=[ + DifyPluginToolConfig( + plugin_id="langgenius/tools", + provider="search", + tool_name="web_search", + credential_type="api-key", + description="Search the web.", + credentials={"api_key": "secret"}, + runtime_parameters={"api_version": "2026-01"}, + parameters=_prepared_tool_parameters(), + parameters_json_schema=_prepared_tool_schema(), + ) + ] + ) + + +def _execution_context_provider() -> LayerProvider[DifyExecutionContextLayer]: return LayerProvider.from_factory( - layer_type=DifyPluginLayer, - create=lambda config: DifyPluginLayer.from_config_with_settings( - DifyPluginLayerConfig.model_validate(config), + layer_type=DifyExecutionContextLayer, + create=lambda config: DifyExecutionContextLayer.from_config_with_settings( + DifyExecutionContextLayerConfig.model_validate(config), daemon_url="http://plugin-daemon", daemon_api_key="daemon-secret", ), ) +def _prepared_tool_parameters() -> list[DifyPluginToolParameter]: + return [ + DifyPluginToolParameter( + name="query", + type=DifyPluginToolParameterType.STRING, + form=DifyPluginToolParameterForm.LLM, + required=True, + llm_description="Search query", + ), + DifyPluginToolParameter( + name="region", + type=DifyPluginToolParameterType.SELECT, + form=DifyPluginToolParameterForm.LLM, + required=False, + llm_description="Search region", + options=[DifyPluginToolOption(value="global"), DifyPluginToolOption(value="cn")], + ), + DifyPluginToolParameter( + name="api_version", + type=DifyPluginToolParameterType.STRING, + form=DifyPluginToolParameterForm.FORM, + required=True, + llm_description="Hidden API version", + ), + DifyPluginToolParameter( + name="auth_scope", + type=DifyPluginToolParameterType.STRING, + form=DifyPluginToolParameterForm.FORM, + required=True, + llm_description="Hidden auth scope", + ), + ] + + +def _prepared_tool_schema() -> dict[str, JsonValue]: + return { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "region": { + "type": "string", + "description": "Search region", + "enum": ["global", "cn"], + }, + }, + "required": ["query"], + } + + +def _llm_only_parameter(*, name: str, description: str, default: JsonValue = None) -> DifyPluginToolParameter: + return DifyPluginToolParameter( + name=name, + type=DifyPluginToolParameterType.STRING, + form=DifyPluginToolParameterForm.LLM, + required=default is None, + default=default, + llm_description=description, + ) + + +def _invoke_stream_response( + *, + error_payload: dict[str, object] | None = None, + chunked_blob: bool = False, +) -> httpx.Response: + if error_payload is not None: + return httpx.Response(400, json=error_payload) + + if chunked_blob: + stream_payload = "\n".join( + [ + f"data: {json.dumps({'code': 0, 'message': 'ok', 'data': {'type': 'blob_chunk', 'message': {'id': 'blob-1', 'sequence': 0, 'total_length': 11, 'blob': 'aGVsbG8g', 'end': False}}})}", + f"data: {json.dumps({'code': 0, 'message': 'ok', 'data': {'type': 'blob_chunk', 'message': {'id': 'blob-1', 'sequence': 1, 'total_length': 11, 'blob': 'd29ybGQ=', 'end': True}}})}", + "", + ] + ) + return httpx.Response(200, text=stream_payload) + + stream_payload = "\n".join( + [ + f"data: {json.dumps({'code': 0, 'message': 'ok', 'data': {'type': 'text', 'message': {'text': 'found '}}})}", + f"data: {json.dumps({'code': 0, 'message': 'ok', 'data': {'type': 'json', 'message': {'json_object': {'count': 1}}}})}", + "", + ] + ) + return httpx.Response(200, text=stream_payload) + + +def _tool_transport( + *, + invoke_error_payload: dict[str, object] | None = None, + chunked_blob: bool = False, +) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/dispatch/tool/invoke"): + payload = json.loads(request.content.decode("utf-8")) + assert payload["user_id"] == "user-1" + assert payload["data"]["provider"] == "search" + assert payload["data"]["tool"] == "web_search" + assert payload["data"]["credential_type"] == "api-key" + assert payload["data"]["tool_parameters"] == { + "query": "dify", + "region": "global", + "api_version": "2026-01", + "auth_scope": "workspace", + } + return _invoke_stream_response(error_payload=invoke_error_payload, chunked_blob=chunked_blob) + + raise AssertionError(f"Unexpected request path: {request.url.path}") + + return httpx.MockTransport(handler) + + def test_dify_plugin_type_id_constants_match_implementation_classes() -> None: - assert DIFY_PLUGIN_LAYER_TYPE_ID == DifyPluginLayer.type_id assert DIFY_PLUGIN_LLM_LAYER_TYPE_ID == DifyPluginLLMLayer.type_id - - -def test_dify_plugin_layer_creates_daemon_provider_from_shared_http_client() -> None: - async def scenario() -> None: - plugin = _plugin_layer() - async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client: - provider = plugin.create_daemon_provider(http_client=client) - - assert provider.name == "DifyPlugin/langgenius/openai" - assert provider.client.http_client is client - assert provider.client.tenant_id == "tenant-1" - assert provider.client.plugin_id == "langgenius/openai" - assert provider.client.user_id == "user-1" - - async with provider: - pass - assert client.is_closed is False - - asyncio.run(scenario()) - - -def test_dify_plugin_layer_rejects_closed_shared_http_client() -> None: - async def scenario() -> None: - plugin = _plugin_layer() - client = httpx.AsyncClient() - await client.aclose() - - with pytest.raises(RuntimeError, match="open shared HTTP client"): - _ = plugin.create_daemon_provider(http_client=client) - - asyncio.run(scenario()) + assert DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID == DifyPluginToolsLayer.type_id def test_dify_plugin_llm_layer_builds_adapter_model_from_direct_dependency() -> None: async def scenario() -> None: compositor = Compositor( [ - LayerNode("renamed-plugin", _plugin_provider()), - LayerNode("llm", DifyPluginLLMLayer, deps={"plugin": "renamed-plugin"}), + LayerNode("renamed-execution-context", _execution_context_provider()), + LayerNode("llm", DifyPluginLLMLayer, deps={"execution_context": "renamed-execution-context"}), ] ) async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client: async with compositor.enter( configs={ - "renamed-plugin": _plugin_config(), + "renamed-execution-context": _execution_context_config(), "llm": _llm_config(), } ) as run: - plugin = run.get_layer("renamed-plugin", DifyPluginLayer) + execution_context = run.get_layer("renamed-execution-context", DifyExecutionContextLayer) llm = run.get_layer("llm", DifyPluginLLMLayer) model = llm.get_model(http_client=client) - assert llm.deps.plugin is plugin + assert llm.deps.execution_context is execution_context assert isinstance(model, DifyLLMAdapterModel) assert model.model_name == "demo-model" assert model.model_provider == "openai" @@ -114,17 +234,436 @@ def test_dify_plugin_llm_layer_builds_adapter_model_from_direct_dependency() -> asyncio.run(scenario()) -def test_dify_plugin_layer_lifecycle_does_not_manage_http_client() -> None: +def test_dify_plugin_tools_layer_uses_prepared_tool_definition_and_invokes_daemon() -> None: async def scenario() -> None: - compositor = Compositor([LayerNode("plugin", _plugin_provider())]) - async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client: - async with compositor.enter(configs={"plugin": _plugin_config()}) as run: - plugin = run.get_layer("plugin", DifyPluginLayer) - provider = plugin.create_daemon_provider(http_client=client) - run.suspend_layer_on_exit("plugin") + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=_tool_transport()) as client: + async with compositor.enter( + configs={"execution_context": _execution_context_config(), "tools": _tools_config()} + ) as run: + tools_layer = run.get_layer("tools", DifyPluginToolsLayer) + tool = (await tools_layer.get_tools(http_client=client))[0] - assert run.session_snapshot is not None - assert provider.client.http_client is client - assert client.is_closed is False + tool_def = await tool.prepare_tool_def(None) # pyright: ignore[reportArgumentType] + result = await tool.function_schema.call( + {"query": "dify", "region": "global"}, + None, # pyright: ignore[reportArgumentType] + ) + + assert tool.name == "web_search" + assert tool.description == "Search the web." + assert tool_def is not None + assert tool_def.parameters_json_schema == _prepared_tool_schema() + assert tool_def.strict is False + assert result == 'found {"count": 1}' + + asyncio.run(scenario()) + + +def test_dify_plugin_tools_layer_uses_each_tool_plugin_id_for_transport() -> None: + async def scenario() -> None: + seen_requests: list[tuple[str, str, str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/dispatch/tool/invoke"): + payload = json.loads(request.content.decode("utf-8")) + seen_requests.append( + ( + request.headers["X-Plugin-ID"], + payload["user_id"], + payload["data"]["provider"], + payload["data"]["tool"], + ) + ) + return _invoke_stream_response() + + raise AssertionError(f"Unexpected request path: {request.url.path}") + + tools_config = DifyPluginToolsLayerConfig( + tools=[ + DifyPluginToolConfig( + plugin_id="langgenius/tools-a", + provider="search-a", + tool_name="web_search_a", + credential_type="api-key", + parameters=[_llm_only_parameter(name="query", description="Search query A")], + parameters_json_schema={ + "type": "object", + "properties": {"query": {"type": "string", "description": "Search query A"}}, + "required": ["query"], + }, + ), + DifyPluginToolConfig( + plugin_id="langgenius/tools-b", + provider="search-b", + tool_name="web_search_b", + credential_type="api-key", + parameters=[_llm_only_parameter(name="query", description="Search query B")], + parameters_json_schema={ + "type": "object", + "properties": {"query": {"type": "string", "description": "Search query B"}}, + "required": ["query"], + }, + ), + ] + ) + + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + async with compositor.enter( + configs={"execution_context": _execution_context_config(), "tools": tools_config} + ) as run: + tools = await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client) + + await tools[0].function_schema.call({"query": "first"}, None) # pyright: ignore[reportArgumentType] + await tools[1].function_schema.call({"query": "second"}, None) # pyright: ignore[reportArgumentType] + + assert seen_requests == [ + ("langgenius/tools-a", "user-1", "search-a", "web_search_a"), + ("langgenius/tools-b", "user-1", "search-b", "web_search_b"), + ] + + asyncio.run(scenario()) + + +def test_dify_plugin_tools_layer_casts_prepared_parameter_values_before_invocation() -> None: + async def scenario() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/dispatch/tool/invoke"): + payload = json.loads(request.content.decode("utf-8")) + assert payload["user_id"] == "user-1" + assert payload["data"]["tool_parameters"] == { + "enabled": True, + "count": 7, + "tags": ["a", "b"], + "metadata": {"source": "docs"}, + "model": {"provider": "openai", "model": "gpt-4o-mini"}, + } + return _invoke_stream_response() + + raise AssertionError(f"Unexpected request path: {request.url.path}") + + tools_config = DifyPluginToolsLayerConfig( + tools=[ + DifyPluginToolConfig( + plugin_id="langgenius/tools", + provider="search", + tool_name="web_search", + credential_type="api-key", + parameters=[ + DifyPluginToolParameter( + name="enabled", + type=DifyPluginToolParameterType.BOOLEAN, + form=DifyPluginToolParameterForm.LLM, + required=True, + llm_description="Enable search", + ), + DifyPluginToolParameter( + name="count", + type=DifyPluginToolParameterType.NUMBER, + form=DifyPluginToolParameterForm.LLM, + required=True, + llm_description="Result count", + ), + DifyPluginToolParameter( + name="tags", + type=DifyPluginToolParameterType.ARRAY, + form=DifyPluginToolParameterForm.LLM, + required=True, + llm_description="Tags", + input_schema={"type": "array", "items": {"type": "string"}}, + ), + DifyPluginToolParameter( + name="metadata", + type=DifyPluginToolParameterType.OBJECT, + form=DifyPluginToolParameterForm.LLM, + required=True, + llm_description="Metadata", + input_schema={"type": "object", "additionalProperties": True}, + ), + DifyPluginToolParameter( + name="model", + type=DifyPluginToolParameterType.MODEL_SELECTOR, + form=DifyPluginToolParameterForm.LLM, + required=True, + llm_description="Model selector", + input_schema={"type": "object", "additionalProperties": True}, + ), + ], + parameters_json_schema={ + "type": "object", + "properties": { + "enabled": {"type": "boolean", "description": "Enable search"}, + "count": {"type": "number", "description": "Result count"}, + "tags": {"type": "array", "items": {"type": "string"}, "description": "Tags"}, + "metadata": { + "type": "object", + "additionalProperties": True, + "description": "Metadata", + }, + "model": { + "type": "object", + "additionalProperties": True, + "description": "Model selector", + }, + }, + "required": ["enabled", "count", "tags", "metadata", "model"], + }, + ) + ] + ) + + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + async with compositor.enter( + configs={"execution_context": _execution_context_config(), "tools": tools_config} + ) as run: + tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0] + + result = await tool.function_schema.call( + { + "enabled": "yes", + "count": "7", + "tags": '["a", "b"]', + "metadata": '{"source": "docs"}', + "model": {"provider": "openai", "model": "gpt-4o-mini"}, + }, + None, # pyright: ignore[reportArgumentType] + ) + + assert result == 'found {"count": 1}' + + asyncio.run(scenario()) + + +def test_dify_plugin_tools_layer_sends_prepared_parameter_defaults_to_daemon() -> None: + async def scenario() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/dispatch/tool/invoke"): + payload = json.loads(request.content.decode("utf-8")) + assert payload["data"]["tool_parameters"] == { + "query": "dify", + "region": "global", + } + return _invoke_stream_response() + + raise AssertionError(f"Unexpected request path: {request.url.path}") + + tools_config = DifyPluginToolsLayerConfig( + tools=[ + DifyPluginToolConfig( + plugin_id="langgenius/tools", + provider="search", + tool_name="web_search", + credential_type="api-key", + parameters=[ + _llm_only_parameter(name="query", description="Search query"), + _llm_only_parameter(name="region", description="Search region", default="global"), + ], + parameters_json_schema={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "region": {"type": "string", "description": "Search region"}, + }, + "required": ["query"], + }, + ) + ] + ) + + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + async with compositor.enter( + configs={"execution_context": _execution_context_config(), "tools": tools_config} + ) as run: + tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0] + + result = await tool.function_schema.call( + {"query": "dify"}, + None, # pyright: ignore[reportArgumentType] + ) + + assert result == 'found {"count": 1}' + + asyncio.run(scenario()) + + +def test_dify_plugin_tools_layer_requires_hidden_runtime_parameters_in_prepared_config() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=_tool_transport()) as client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "tools": _missing_hidden_parameter_tools_config(), + } + ) as run: + with pytest.raises(ValueError, match="requires non-LLM runtime_parameters for: auth_scope"): + await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client) + + asyncio.run(scenario()) + + +def test_dify_plugin_tools_layer_returns_agent_friendly_error_text() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient( + transport=_tool_transport( + invoke_error_payload={ + "error_type": "PluginDaemonBadRequestError", + "message": "missing query", + } + ) + ) as client: + async with compositor.enter( + configs={"execution_context": _execution_context_config(), "tools": _tools_config()} + ) as run: + tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0] + result = await tool.function_schema.call( + {"query": "dify", "region": "global"}, + None, # pyright: ignore[reportArgumentType] + ) + + assert result == "tool parameters validation error: missing query, please check your tool parameters" + + asyncio.run(scenario()) + + +def test_dify_plugin_tools_layer_propagates_unexpected_transport_errors() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + ] + ) + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/dispatch/tool/invoke"): + raise RuntimeError("unexpected transport failure") + + raise AssertionError(f"Unexpected request path: {request.url.path}") + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + async with compositor.enter( + configs={"execution_context": _execution_context_config(), "tools": _tools_config()} + ) as run: + tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0] + + with pytest.raises(RuntimeError, match="unexpected transport failure"): + await tool.function_schema.call( + {"query": "dify", "region": "global"}, + None, # pyright: ignore[reportArgumentType] + ) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + ("invoke_error_payload", "expected_text"), + [ + ( + { + "error_type": "PluginInvokeError", + "message": json.dumps( + { + "error_type": "PluginDaemonUnauthorizedError", + "message": "invalid api key", + } + ), + }, + "Please check your tool provider credentials", + ), + ( + { + "error_type": "PluginInvokeError", + "message": json.dumps( + { + "error_type": "ToolNotFoundError", + "message": "missing plugin tool", + } + ), + }, + "there is not a tool named web_search", + ), + ], +) +def test_dify_plugin_tools_layer_maps_nested_plugin_invoke_errors_to_agent_text( + invoke_error_payload: dict[str, object], + expected_text: str, +) -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=_tool_transport(invoke_error_payload=invoke_error_payload)) as client: + async with compositor.enter( + configs={"execution_context": _execution_context_config(), "tools": _tools_config()} + ) as run: + tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0] + result = await tool.function_schema.call( + {"query": "dify", "region": "global"}, + None, # pyright: ignore[reportArgumentType] + ) + + assert result == expected_text + + asyncio.run(scenario()) + + +def test_dify_plugin_tools_layer_merges_blob_chunks_before_observation_conversion() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=_tool_transport(chunked_blob=True)) as client: + async with compositor.enter( + configs={"execution_context": _execution_context_config(), "tools": _tools_config()} + ) as run: + tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0] + result = await tool.function_schema.call( + {"query": "dify", "region": "global"}, + None, # pyright: ignore[reportArgumentType] + ) + + assert "hello world" in result + assert "sequence=0" not in result asyncio.run(scenario()) diff --git a/dify-agent/tests/local/dify_agent/layers/execution_context/test_configs.py b/dify-agent/tests/local/dify_agent/layers/execution_context/test_configs.py new file mode 100644 index 0000000000..691a483b65 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/execution_context/test_configs.py @@ -0,0 +1,47 @@ +import pytest +from pydantic import ValidationError + +import dify_agent.layers.execution_context as execution_context_exports +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig + + +def test_execution_context_package_exports_client_safe_config_symbols_only() -> None: + assert execution_context_exports.__all__ == [ + "DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID", + "DifyExecutionContextInvokeFrom", + "DifyExecutionContextLayerConfig", + ] + assert execution_context_exports.DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID == "dify.execution_context" + assert not hasattr(execution_context_exports, "DifyExecutionContextLayer") + + +def test_execution_context_layer_config_forbids_runtime_settings_and_unknown_fields() -> None: + config = DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_id="user-1", + workflow_id="workflow-1", + invoke_from="workflow_run", + ) + + assert config.tenant_id == "tenant-1" + assert config.user_id == "user-1" + assert config.workflow_id == "workflow-1" + assert config.invoke_from == "workflow_run" + + with pytest.raises(ValidationError): + _ = DifyExecutionContextLayerConfig.model_validate( + { + "tenant_id": "tenant-1", + "invoke_from": "workflow_run", + "daemon_url": "http://daemon", + } + ) + + with pytest.raises(ValidationError): + _ = DifyExecutionContextLayerConfig.model_validate( + { + "tenant_id": "tenant-1", + "invoke_from": "workflow_run", + "unknown": "value", + } + ) diff --git a/dify-agent/tests/local/dify_agent/layers/execution_context/test_layer.py b/dify-agent/tests/local/dify_agent/layers/execution_context/test_layer.py new file mode 100644 index 0000000000..6757cf7d7b --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/execution_context/test_layer.py @@ -0,0 +1,107 @@ +import asyncio + +import httpx +import pytest + +from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig +from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer + + +def _execution_context_layer() -> DifyExecutionContextLayer: + return DifyExecutionContextLayer.from_config_with_settings( + DifyExecutionContextLayerConfig(tenant_id="tenant-1", user_id="user-1", invoke_from="workflow_run"), + daemon_url="http://plugin-daemon", + daemon_api_key="daemon-secret", + ) + + +def test_execution_context_type_id_constant_matches_implementation_class() -> None: + assert DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID == DifyExecutionContextLayer.type_id + + +def test_execution_context_layer_creates_daemon_provider_from_shared_http_client() -> None: + async def scenario() -> None: + execution_context = _execution_context_layer() + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client: + provider = execution_context.create_daemon_provider(plugin_id="langgenius/openai", http_client=client) + + assert provider.name == "DifyPlugin/langgenius/openai" + assert provider.client.http_client is client + assert provider.client.tenant_id == "tenant-1" + assert provider.client.plugin_id == "langgenius/openai" + assert provider.client.user_id == "user-1" + + async with provider: + pass + assert client.is_closed is False + + asyncio.run(scenario()) + + +def test_execution_context_layer_creates_tool_client_from_shared_http_client() -> None: + async def scenario() -> None: + execution_context = _execution_context_layer() + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client: + tool_client = execution_context.create_tool_client(plugin_id="langgenius/tools", http_client=client) + + assert tool_client.http_client is client + assert tool_client.tenant_id == "tenant-1" + assert tool_client.user_id == "user-1" + assert tool_client.plugin_id == "langgenius/tools" + assert tool_client.plugin_daemon_url == "http://plugin-daemon" + assert tool_client.plugin_daemon_api_key == "daemon-secret" + assert client.is_closed is False + + asyncio.run(scenario()) + + +def test_execution_context_layer_rejects_closed_shared_http_client() -> None: + async def scenario() -> None: + execution_context = _execution_context_layer() + client = httpx.AsyncClient() + await client.aclose() + + with pytest.raises(RuntimeError, match="open shared HTTP client"): + _ = execution_context.create_daemon_provider(plugin_id="langgenius/openai", http_client=client) + with pytest.raises(RuntimeError, match="open shared HTTP client"): + _ = execution_context.create_tool_client(plugin_id="langgenius/tools", http_client=client) + + asyncio.run(scenario()) + + +def test_execution_context_layer_lifecycle_does_not_manage_http_client() -> None: + from agenton.compositor import Compositor, LayerNode, LayerProvider + + provider = LayerProvider.from_factory( + layer_type=DifyExecutionContextLayer, + create=lambda config: DifyExecutionContextLayer.from_config_with_settings( + DifyExecutionContextLayerConfig.model_validate(config), + daemon_url="http://plugin-daemon", + daemon_api_key="daemon-secret", + ), + ) + + async def scenario() -> None: + compositor = Compositor([LayerNode("execution_context", provider)]) + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client: + async with compositor.enter( + configs={ + "execution_context": DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_id="user-1", + invoke_from="workflow_run", + ) + } + ) as run: + execution_context = run.get_layer("execution_context", DifyExecutionContextLayer) + daemon_provider = execution_context.create_daemon_provider( + plugin_id="langgenius/openai", + http_client=client, + ) + run.suspend_layer_on_exit("execution_context") + + assert run.session_snapshot is not None + assert daemon_provider.client.http_client is client + assert client.is_closed is False + + asyncio.run(scenario()) diff --git a/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py b/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py index ce39511302..e64eb4953f 100644 --- a/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py +++ b/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py @@ -6,13 +6,13 @@ from agenton.compositor import CompositorSessionSnapshot from agenton.layers import ExitIntent from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig import dify_agent.protocol as protocol_exports -from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LAYER_TYPE_ID, DIFY_PLUGIN_LLM_LAYER_TYPE_ID +from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig +from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID from dify_agent.protocol.schemas import ( RUN_EVENT_ADAPTER, CreateRunRequest, - ExecutionContext, LayerExitSignals, PydanticAIStreamRunEvent, RunCancelledEvent, @@ -28,7 +28,14 @@ from dify_agent.protocol.schemas import ( RunSucceededEventData, normalize_composition, ) -from dify_agent.layers.dify_plugin.configs import DifyPluginLLMLayerConfig, DifyPluginLayerConfig +from dify_agent.layers.dify_plugin.configs import ( + DifyPluginLLMLayerConfig, + DifyPluginToolConfig, + DifyPluginToolParameter, + DifyPluginToolParameterForm, + DifyPluginToolParameterType, + DifyPluginToolsLayerConfig, +) def test_run_event_adapter_round_trips_typed_variants() -> None: @@ -87,10 +94,23 @@ def test_create_run_request_rejects_old_compositor_payload_and_model_layer_id_is ) +def test_protocol_package_no_longer_exports_execution_context_dto() -> None: + assert not hasattr(protocol_exports, "ExecutionContext") + + def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_graph_config() -> None: prompt_config = PromptLayerConfig(prefix="system", user="hello") - plugin_config = DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai") + execution_context_config = DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + workflow_id="workflow-1", + workflow_run_id="workflow-run-1", + node_id="node-1", + node_execution_id="node-execution-1", + invoke_from="workflow_run", + trace_id="trace-1", + ) llm_config = DifyPluginLLMLayerConfig( + plugin_id="langgenius/openai", model_provider="openai", model="demo-model", credentials={"api_key": "secret"}, @@ -104,26 +124,21 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_ } ) request = CreateRunRequest( - execution_context=ExecutionContext( - tenant_id="tenant-1", - workflow_id="workflow-1", - workflow_run_id="workflow-run-1", - node_id="node-1", - node_execution_id="node-execution-1", - invoke_from="workflow_run", - trace_id="trace-1", - ), purpose="workflow_node", idempotency_key="workflow-run-1:node-execution-1", metadata={"source": "unit_test"}, composition=RunComposition( layers=[ RunLayerSpec(name="prompt", type=PLAIN_PROMPT_LAYER_TYPE_ID, config=prompt_config), - RunLayerSpec(name="plugin", type=DIFY_PLUGIN_LAYER_TYPE_ID, config=plugin_config), + RunLayerSpec( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=execution_context_config, + ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID, - deps={"plugin": "plugin"}, + deps={"execution_context": "execution_context"}, config=llm_config, ), RunLayerSpec( @@ -138,8 +153,9 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_ graph_config, layer_configs = normalize_composition(request.composition) payload = request.model_dump(mode="json") - assert payload["execution_context"] == { + assert payload["composition"]["layers"][1]["config"] == { "tenant_id": "tenant-1", + "user_id": None, "app_id": None, "workflow_id": "workflow-1", "workflow_run_id": "workflow-run-1", @@ -157,11 +173,16 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_ assert payload["composition"]["layers"][0]["config"] == {"prefix": "system", "user": "hello", "suffix": []} assert [layer.model_dump(mode="json") for layer in graph_config.layers] == [ {"name": "prompt", "type": PLAIN_PROMPT_LAYER_TYPE_ID, "deps": {}, "metadata": {}}, - {"name": "plugin", "type": DIFY_PLUGIN_LAYER_TYPE_ID, "deps": {}, "metadata": {}}, + { + "name": "execution_context", + "type": DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + "deps": {}, + "metadata": {}, + }, { "name": DIFY_AGENT_MODEL_LAYER_ID, "type": DIFY_PLUGIN_LLM_LAYER_TYPE_ID, - "deps": {"plugin": "plugin"}, + "deps": {"execution_context": "execution_context"}, "metadata": {}, }, { @@ -173,12 +194,118 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_ ] assert layer_configs == { "prompt": prompt_config, - "plugin": plugin_config, + "execution_context": execution_context_config, DIFY_AGENT_MODEL_LAYER_ID: llm_config, DIFY_AGENT_OUTPUT_LAYER_ID: output_config, } +def test_create_run_request_accepts_plugin_tools_layer_with_prepared_parameters_and_schema() -> None: + request = CreateRunRequest.model_validate( + { + "composition": { + "layers": [ + {"name": "prompt", "type": PLAIN_PROMPT_LAYER_TYPE_ID, "config": {"user": "hello"}}, + { + "name": "execution_context", + "type": DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + "config": {"tenant_id": "tenant-1", "invoke_from": "workflow_run"}, + }, + { + "name": DIFY_AGENT_MODEL_LAYER_ID, + "type": DIFY_PLUGIN_LLM_LAYER_TYPE_ID, + "deps": {"execution_context": "execution_context"}, + "config": { + "plugin_id": "langgenius/openai", + "model_provider": "openai", + "model": "demo-model", + "credentials": {"api_key": "secret"}, + }, + }, + { + "name": "tools", + "type": DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, + "deps": {"execution_context": "execution_context"}, + "config": { + "tools": [ + { + "plugin_id": "langgenius/search", + "provider": "search", + "tool_name": "web_search", + "credential_type": "api-key", + "runtime_parameters": {"site": "docs.dify.ai"}, + "parameters": [ + { + "name": "query", + "type": "string", + "form": "llm", + "required": True, + "llm_description": "Search query", + }, + { + "name": "site", + "type": "string", + "form": "form", + "required": True, + "llm_description": "Hidden site", + }, + ], + "parameters_json_schema": { + "type": "object", + "properties": {"query": {"type": "string", "description": "Search query"}}, + "required": ["query"], + }, + } + ] + }, + }, + ] + } + } + ) + + graph_config, layer_configs = normalize_composition(request.composition) + + assert [layer.type for layer in graph_config.layers] == [ + PLAIN_PROMPT_LAYER_TYPE_ID, + DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + DIFY_PLUGIN_LLM_LAYER_TYPE_ID, + DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, + ] + assert DifyPluginToolsLayerConfig.model_validate(layer_configs["tools"]) == DifyPluginToolsLayerConfig( + tools=[ + DifyPluginToolConfig( + plugin_id="langgenius/search", + provider="search", + tool_name="web_search", + credential_type="api-key", + runtime_parameters={"site": "docs.dify.ai"}, + parameters=[ + DifyPluginToolParameter( + name="query", + type=DifyPluginToolParameterType.STRING, + form=DifyPluginToolParameterForm.LLM, + required=True, + llm_description="Search query", + ), + DifyPluginToolParameter( + name="site", + type=DifyPluginToolParameterType.STRING, + form=DifyPluginToolParameterForm.FORM, + required=True, + llm_description="Hidden site", + ), + ], + parameters_json_schema={ + "type": "object", + "properties": {"query": {"type": "string", "description": "Search query"}}, + "required": ["query"], + }, + ) + ] + ) + + def test_on_exit_default_to_suspend_and_are_public() -> None: assert protocol_exports.LayerExitSignals is LayerExitSignals assert protocol_exports.RunComposition is RunComposition @@ -206,13 +333,12 @@ def test_on_exit_accept_layer_overrides() -> None: assert request.on_exit.layers == {"prompt": ExitIntent.SUSPEND, "llm": ExitIntent.DELETE} -def test_execution_context_rejects_unknown_fields() -> None: +def test_create_run_request_rejects_removed_top_level_execution_context() -> None: with pytest.raises(ValidationError): - _ = ExecutionContext.model_validate( + _ = CreateRunRequest.model_validate( { - "tenant_id": "tenant-1", - "invoke_from": "workflow_run", - "unknown": "value", + "composition": {"layers": []}, + "execution_context": {"tenant_id": "tenant-1", "invoke_from": "workflow_run"}, } ) diff --git a/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py b/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py index d5fce5adb1..a4a5ad8429 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py @@ -6,25 +6,18 @@ import httpx import pytest from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot -from agenton.layers import ExitIntent, LifecycleState -from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID +from agenton.layers import LifecycleState from agenton_collections.layers.plain import PromptLayerConfig from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig -from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID +from dify_agent.protocol import DIFY_AGENT_OUTPUT_LAYER_ID from dify_agent.protocol.schemas import ( CreateRunRequest, - LayerExitSignals, RunComposition, RunEvent, RunLayerSpec, RunStatus, ) -from dify_agent.runtime.run_scheduler import ( - RunRequestValidationError, - RunScheduler, - SchedulerStoppingError, - validate_run_request, -) +from dify_agent.runtime.run_scheduler import RunScheduler, SchedulerStoppingError from dify_agent.server.schemas import RunRecord @@ -168,390 +161,64 @@ def test_shutdown_marks_unfinished_runs_failed_and_appends_event() -> None: asyncio.run(scenario()) -def test_create_run_rejects_blank_prompt_before_persisting() -> None: +def test_create_run_accepts_blank_prompt_and_runner_fails_asynchronously() -> None: async def scenario() -> None: store = FakeStore() async with httpx.AsyncClient() as client: scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) - with pytest.raises(ValueError, match="run.user_prompts must not be empty"): - await scheduler.create_run(_request(["", " "])) + record = await scheduler.create_run(_request(["", " "])) + await asyncio.wait_for(scheduler.active_tasks[record.run_id], timeout=1) - assert store.records == {} + assert store.records == {record.run_id: record} + assert [event.type for event in store.events[record.run_id]] == ["run_started", "run_failed"] + assert store.statuses[record.run_id] == "failed" + assert store.errors[record.run_id] == "run.user_prompts must not be empty" asyncio.run(scenario()) -def test_create_run_rejects_invalid_output_schema_before_persisting() -> None: +def test_create_run_accepts_invalid_output_schema_and_runner_fails_asynchronously() -> None: async def scenario() -> None: store = FakeStore() async with httpx.AsyncClient() as client: scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) - with pytest.raises(ValueError, match=r"Recursive \$defs refs are not supported"): - await scheduler.create_run( - _request( - output_config={ - "json_schema": _recursive_output_schema(), - } - ) - ) - - assert store.records == {} - - asyncio.run(scenario()) - - -def test_create_run_rejects_remote_ref_output_schema_before_persisting() -> None: - async def scenario() -> None: - store = FakeStore() - async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) - - with pytest.raises(ValueError, match=r"Remote \$ref values are not supported"): - await scheduler.create_run( - _request( - output_config={ - "json_schema": { - "type": "object", - "properties": { - "title": {"$ref": "https://example.com/schema.json"}, - }, - }, - } - ) - ) - - assert store.records == {} - - asyncio.run(scenario()) - - -def test_create_run_rejects_non_object_output_schema_before_persisting() -> None: - async def scenario() -> None: - store = FakeStore() - async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) - - with pytest.raises(ValueError, match="Schema must declare an object output"): - await scheduler.create_run( - _request( - output_config={ - "json_schema": { - "type": "array", - "items": {"type": "string"}, - }, - } - ) - ) - - assert store.records == {} - - asyncio.run(scenario()) - - -def test_create_run_rejects_public_output_tool_name_override_before_persisting() -> None: - async def scenario() -> None: - store = FakeStore() - async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) - - with pytest.raises(ValueError, match="Extra inputs are not permitted"): - await scheduler.create_run( - _request( - output_config={ - "name": "incident_summary", - "json_schema": { - "type": "object", - "properties": {"title": {"type": "string"}}, - "required": ["title"], - "additionalProperties": False, - }, - } - ) - ) - - assert store.records == {} - - asyncio.run(scenario()) - - -def test_create_run_rejects_non_defs_local_ref_in_direct_object_schema_before_persisting() -> None: - async def scenario() -> None: - store = FakeStore() - async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) - - with pytest.raises(ValueError, match=r"Only local refs under '#/\$defs/' are supported"): - await scheduler.create_run( - _request( - output_config={ - "json_schema": { - "type": "object", - "properties": { - "items": {"$ref": "#/definitions/itemArray"}, - }, - "required": ["items"], - "definitions": { - "itemArray": { - "type": "array", - "items": {"type": "string"}, - }, - }, - }, - } - ) - ) - - assert store.records == {} - - asyncio.run(scenario()) - - -def test_create_run_rejects_misnamed_output_layer_before_persisting() -> None: - async def scenario() -> None: - store = FakeStore() - async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) - - request = CreateRunRequest( - composition=RunComposition( - layers=[ - RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")), - RunLayerSpec( - name="structured-output", - type=DIFY_OUTPUT_LAYER_TYPE_ID, - config=DifyOutputLayerConfig( - json_schema={ - "type": "object", - "properties": {"title": {"type": "string"}}, - "required": ["title"], - "additionalProperties": False, - } - ), - ), - ] + record = await scheduler.create_run( + _request( + output_config={ + "json_schema": _recursive_output_schema(), + } ) ) + await asyncio.wait_for(scheduler.active_tasks[record.run_id], timeout=1) - with pytest.raises(ValueError, match="must use reserved layer name 'output'"): - await scheduler.create_run(request) - - assert store.records == {} + assert store.records == {record.run_id: record} + assert [event.type for event in store.events[record.run_id]] == ["run_started", "run_failed"] + assert store.statuses[record.run_id] == "failed" + assert "Recursive $defs refs are not supported" in (store.errors[record.run_id] or "") asyncio.run(scenario()) -def test_create_run_rejects_multiple_output_layers_before_persisting() -> None: - async def scenario() -> None: - store = FakeStore() - async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) - - request = CreateRunRequest( - composition=RunComposition( - layers=[ - RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")), - RunLayerSpec( - name=DIFY_AGENT_OUTPUT_LAYER_ID, - type=DIFY_OUTPUT_LAYER_TYPE_ID, - config=DifyOutputLayerConfig( - json_schema={ - "type": "object", - "properties": {"title": {"type": "string"}}, - "required": ["title"], - "additionalProperties": False, - } - ), - ), - RunLayerSpec( - name="secondary-output", - type=DIFY_OUTPUT_LAYER_TYPE_ID, - config=DifyOutputLayerConfig( - json_schema={ - "type": "object", - "properties": {"summary": {"type": "string"}}, - "required": ["summary"], - "additionalProperties": False, - } - ), - ), - ] - ) - ) - - with pytest.raises(ValueError, match="Only one 'dify.output' layer is supported"): - await scheduler.create_run(request) - - assert store.records == {} - - asyncio.run(scenario()) - - -def test_create_run_rejects_reserved_output_name_with_wrong_layer_type_before_persisting() -> None: - async def scenario() -> None: - store = FakeStore() - async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) - - request = CreateRunRequest( - composition=RunComposition( - layers=[ - RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")), - RunLayerSpec( - name=DIFY_AGENT_OUTPUT_LAYER_ID, type="plain.prompt", config=PromptLayerConfig(user="hi") - ), - ] - ) - ) - - with pytest.raises(ValueError, match=r"Layer 'output' must be DifyOutputLayer, got PromptLayer"): - await scheduler.create_run(request) - - assert store.records == {} - - asyncio.run(scenario()) - - -def test_validate_run_request_honors_explicit_empty_layer_providers() -> None: - async def scenario() -> None: - with pytest.raises(RunRequestValidationError, match="plain.prompt"): - await validate_run_request(_request(), layer_providers=()) - - asyncio.run(scenario()) - - -def test_validate_run_request_rejects_misnamed_output_layer_before_provider_checks() -> None: - async def scenario() -> None: - request = CreateRunRequest( - composition=RunComposition( - layers=[ - RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")), - RunLayerSpec( - name="structured-output", - type=DIFY_OUTPUT_LAYER_TYPE_ID, - config=DifyOutputLayerConfig( - json_schema={ - "type": "object", - "properties": {"title": {"type": "string"}}, - "required": ["title"], - "additionalProperties": False, - } - ), - ), - ] - ) - ) - - with pytest.raises(RunRequestValidationError, match="must use reserved layer name 'output'"): - await validate_run_request(request, layer_providers=()) - - asyncio.run(scenario()) - - -def test_validate_run_request_accepts_reserved_history_layer() -> None: - async def scenario() -> None: - request = CreateRunRequest( - composition=RunComposition( - layers=[ - RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")), - RunLayerSpec(name=DIFY_AGENT_HISTORY_LAYER_ID, type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID), - ] - ) - ) - - await validate_run_request(request) - - asyncio.run(scenario()) - - -def test_validate_run_request_rejects_misnamed_history_layer_before_provider_checks() -> None: - async def scenario() -> None: - request = CreateRunRequest( - composition=RunComposition( - layers=[ - RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")), - RunLayerSpec(name="chat-history", type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID), - ] - ) - ) - - with pytest.raises(RunRequestValidationError, match="must use reserved layer name 'history'"): - await validate_run_request(request, layer_providers=()) - - asyncio.run(scenario()) - - -def test_validate_run_request_rejects_multiple_history_layers_before_provider_checks() -> None: - async def scenario() -> None: - request = CreateRunRequest( - composition=RunComposition( - layers=[ - RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")), - RunLayerSpec(name=DIFY_AGENT_HISTORY_LAYER_ID, type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID), - RunLayerSpec(name="secondary-history", type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID), - ] - ) - ) - - with pytest.raises(RunRequestValidationError, match="Only one 'pydantic_ai.history' layer is supported"): - await validate_run_request(request, layer_providers=()) - - asyncio.run(scenario()) - - -def test_validate_run_request_rejects_history_layer_dependencies_before_provider_checks() -> None: - async def scenario() -> None: - request = CreateRunRequest( - composition=RunComposition( - layers=[ - RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")), - RunLayerSpec( - name=DIFY_AGENT_HISTORY_LAYER_ID, - type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, - deps={"prompt": "prompt"}, - ), - ] - ) - ) - - with pytest.raises(RunRequestValidationError, match="does not support dependencies"): - await validate_run_request(request, layer_providers=()) - - asyncio.run(scenario()) - - -def test_create_run_rejects_unknown_layer_exit_signal_before_persisting() -> None: - async def scenario() -> None: - store = FakeStore() - async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) - request = _request() - request.on_exit = LayerExitSignals(layers={"missing": ExitIntent.DELETE}) - - with pytest.raises(ValueError, match="missing"): - await scheduler.create_run(request) - - assert store.records == {} - - asyncio.run(scenario()) - - -def test_create_run_honors_explicit_empty_layer_providers_before_persisting() -> None: +def test_create_run_honors_explicit_empty_layer_providers_by_failing_after_persisting() -> None: async def scenario() -> None: store = FakeStore() async with httpx.AsyncClient() as client: scheduler = RunScheduler(store=store, plugin_daemon_http_client=client, layer_providers=()) - with pytest.raises(RunRequestValidationError, match="plain.prompt"): - await scheduler.create_run(_request()) + record = await scheduler.create_run(_request()) + await asyncio.wait_for(scheduler.active_tasks[record.run_id], timeout=1) - assert store.records == {} + assert store.records == {record.run_id: record} + assert [event.type for event in store.events[record.run_id]] == ["run_started", "run_failed"] + assert store.statuses[record.run_id] == "failed" + assert "plain.prompt" in (store.errors[record.run_id] or "") asyncio.run(scenario()) -def test_create_run_rejects_closed_session_snapshot_before_persisting() -> None: +def test_create_run_accepts_closed_session_snapshot_and_runner_fails_asynchronously() -> None: async def scenario() -> None: store = FakeStore() async with httpx.AsyncClient() as client: @@ -567,10 +234,13 @@ def test_create_run_rejects_closed_session_snapshot_before_persisting() -> None: ] ) - with pytest.raises(ValueError, match="CLOSED snapshots cannot be entered"): - _ = await scheduler.create_run(request) + record = await scheduler.create_run(request) + await asyncio.wait_for(scheduler.active_tasks[record.run_id], timeout=1) - assert store.records == {} + assert store.records == {record.run_id: record} + assert [event.type for event in store.events[record.run_id]] == ["run_started", "run_failed"] + assert store.statuses[record.run_id] == "failed" + assert "CLOSED snapshots cannot be entered" in (store.errors[record.run_id] or "") asyncio.run(scenario()) diff --git a/dify-agent/tests/local/dify_agent/runtime/test_runner.py b/dify-agent/tests/local/dify_agent/runtime/test_runner.py index ddf860beb6..6683f982a8 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_runner.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_runner.py @@ -1,9 +1,11 @@ import asyncio from collections.abc import Mapping -from typing import Any +from typing import Any, ClassVar, cast import httpx import pytest +from pydantic import JsonValue +from pydantic_ai import Tool from pydantic_ai.exceptions import UnexpectedModelBehavior from pydantic_ai.messages import ( ModelMessage, @@ -18,12 +20,22 @@ from pydantic_ai.models import ModelRequestParameters from pydantic_ai.models.test import TestModel from pydantic_ai.settings import ModelSettings -from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot +from agenton.compositor import CompositorSessionSnapshot, LayerProvider, LayerSessionSnapshot from agenton.layers import ExitIntent, LifecycleState from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, PydanticAIHistoryRuntimeState -from agenton_collections.layers.plain import PromptLayerConfig -from dify_agent.layers.dify_plugin.configs import DifyPluginLLMLayerConfig, DifyPluginLayerConfig +from agenton_collections.layers.plain import PromptLayerConfig, ToolsLayer +from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig +from dify_agent.layers.dify_plugin.configs import ( + DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, + DifyPluginLLMLayerConfig, + DifyPluginToolConfig, + DifyPluginToolParameter, + DifyPluginToolParameterForm, + DifyPluginToolParameterType, + DifyPluginToolsLayerConfig, +) from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer +from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID from dify_agent.protocol.schemas import ( @@ -34,15 +46,20 @@ from dify_agent.protocol.schemas import ( RunSucceededEvent, ) from dify_agent.runtime.event_sink import InMemoryRunEventSink +from dify_agent.runtime.compositor_factory import create_default_layer_providers from dify_agent.runtime.runner import AgentRunRunner, AgentRunValidationError +class StaticToolsTestLayer(ToolsLayer): + type_id: ClassVar[str] = "test.static.tools" + + def _request( user: str | list[str] = "hello", *, include_history: bool = False, llm_layer_name: str = DIFY_AGENT_MODEL_LAYER_ID, - plugin_layer_name: str = "plugin", + execution_context_layer_name: str = "execution_context", on_exit: LayerExitSignals | None = None, output_config: Mapping[str, object] | DifyOutputLayerConfig | None = None, ) -> CreateRunRequest: @@ -58,15 +75,16 @@ def _request( else [] ), RunLayerSpec( - name=plugin_layer_name, - type="dify.plugin", - config=DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai"), + name=execution_context_layer_name, + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), ), RunLayerSpec( name=llm_layer_name, type="dify.plugin.llm", - deps={"plugin": plugin_layer_name}, + deps={"execution_context": execution_context_layer_name}, config=DifyPluginLLMLayerConfig( + plugin_id="langgenius/openai", model_provider="openai", model="demo-model", credentials={"api_key": "secret"}, @@ -103,6 +121,35 @@ def _recursive_output_schema() -> dict[str, object]: } +def _prepared_plugin_tool_parameters() -> list[DifyPluginToolParameter]: + return [ + DifyPluginToolParameter( + name="query", + type=DifyPluginToolParameterType.STRING, + form=DifyPluginToolParameterForm.LLM, + required=True, + llm_description="Search query", + ), + DifyPluginToolParameter( + name="auth_scope", + type=DifyPluginToolParameterType.STRING, + form=DifyPluginToolParameterForm.FORM, + required=True, + llm_description="Hidden auth scope", + ), + ] + + +def _prepared_plugin_tool_schema() -> dict[str, JsonValue]: + return { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + }, + "required": ["query"], + } + + class SequenceOutputTestModel(TestModel): outputs: list[str | dict[str, Any] | None] request_count: int @@ -170,7 +217,7 @@ def _history_session_snapshot( lifecycle_state=LifecycleState.SUSPENDED, runtime_state=PydanticAIHistoryRuntimeState(messages=messages).model_dump(mode="json"), ), - LayerSessionSnapshot(name="plugin", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), + LayerSessionSnapshot(name="execution_context", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), LayerSessionSnapshot( name=DIFY_AGENT_MODEL_LAYER_ID, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={} ), @@ -198,12 +245,12 @@ def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPa def fake_get_model(self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): assert self.config.model == "demo-model" - assert self.deps.plugin.config.plugin_id == "langgenius/openai" + assert self.config.plugin_id == "langgenius/openai" seen_clients.append(http_client) return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) - request = _request(plugin_layer_name="renamed-plugin") + request = _request(execution_context_layer_name="renamed-execution-context") sink = InMemoryRunEventSink() async def scenario() -> None: @@ -230,7 +277,7 @@ def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPa assert terminal.data.output == "done" assert [layer.name for layer in terminal.data.session_snapshot.layers] == [ "prompt", - "renamed-plugin", + "renamed-execution-context", DIFY_AGENT_MODEL_LAYER_ID, ] assert [layer.lifecycle_state for layer in terminal.data.session_snapshot.layers] == [ @@ -241,6 +288,315 @@ def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPa assert sink.statuses["run-1"] == "succeeded" +def test_runner_passes_dynamic_dify_plugin_tools_to_agent(monkeypatch: pytest.MonkeyPatch) -> None: + seen_tools: list[Tool[object]] = [] + + async def plugin_tool() -> str: + return "tool" + + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] + + async def fake_get_tools(self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: + assert self.config.tools[0].tool_name == "web_search" + assert http_client.is_closed is False + return [Tool(plugin_tool, name="web_search")] + + class FakeResult: + output: str = "done" + + def new_messages(self) -> list[ModelMessage]: + return [] + + class FakeAgent: + async def run(self, *_args: object, **_kwargs: object) -> FakeResult: + return FakeResult() + + def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> FakeAgent: + del model, output_type + seen_tools.extend(tools) + return FakeAgent() + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + monkeypatch.setattr(DifyPluginToolsLayer, "get_tools", fake_get_tools) + monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent) + + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig(prefix="system", user="hello"), + ), + RunLayerSpec( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type="dify.plugin.llm", + deps={"execution_context": "execution_context"}, + config=DifyPluginLLMLayerConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="demo-model", + credentials={"api_key": "secret"}, + ), + ), + RunLayerSpec( + name="tools", + type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, + deps={"execution_context": "execution_context"}, + config=DifyPluginToolsLayerConfig( + tools=[ + DifyPluginToolConfig( + plugin_id="langgenius/tools", + provider="search", + tool_name="web_search", + credential_type="api-key", + parameters=_prepared_plugin_tool_parameters(), + parameters_json_schema=_prepared_plugin_tool_schema(), + ) + ] + ), + ), + ] + ) + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-tools", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert [tool.name for tool in seen_tools] == ["web_search"] + terminal = sink.events["run-tools"][-1] + assert isinstance(terminal, RunSucceededEvent) + assert terminal.data.output == "done" + + +def test_runner_rejects_duplicate_tool_names_across_dynamic_tool_layers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + create_agent_called = False + + async def duplicate_tool() -> str: + return "tool" + + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] + + async def fake_get_tools(_self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: + assert http_client.is_closed is False + return [Tool(duplicate_tool, name="shared_tool")] + + def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> object: + del model, tools, output_type + nonlocal create_agent_called + create_agent_called = True + raise AssertionError("create_agent should not be called when duplicate tool names are detected") + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + monkeypatch.setattr(DifyPluginToolsLayer, "get_tools", fake_get_tools) + monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent) + + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig(prefix="system", user="hello"), + ), + RunLayerSpec( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type="dify.plugin.llm", + deps={"execution_context": "execution_context"}, + config=DifyPluginLLMLayerConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="demo-model", + credentials={"api_key": "secret"}, + ), + ), + RunLayerSpec( + name="tools-1", + type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, + deps={"execution_context": "execution_context"}, + config=DifyPluginToolsLayerConfig( + tools=[ + DifyPluginToolConfig( + plugin_id="langgenius/tools", + provider="search", + tool_name="web_search", + credential_type="api-key", + parameters=_prepared_plugin_tool_parameters(), + parameters_json_schema=_prepared_plugin_tool_schema(), + ) + ] + ), + ), + RunLayerSpec( + name="tools-2", + type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, + deps={"execution_context": "execution_context"}, + config=DifyPluginToolsLayerConfig( + tools=[ + DifyPluginToolConfig( + plugin_id="langgenius/tools", + provider="search", + tool_name="web_search_two", + credential_type="api-key", + parameters=_prepared_plugin_tool_parameters(), + parameters_json_schema=_prepared_plugin_tool_schema(), + ) + ] + ), + ), + ] + ) + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises( + AgentRunValidationError, + match="unique tool names across all layers, got duplicates: shared_tool", + ): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-duplicate-tools", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert create_agent_called is False + assert [event.type for event in sink.events["run-duplicate-tools"]] == ["run_started", "run_failed"] + assert sink.statuses["run-duplicate-tools"] == "failed" + + +def test_runner_rejects_duplicate_tool_names_between_static_and_dynamic_tools( + monkeypatch: pytest.MonkeyPatch, +) -> None: + create_agent_called = False + + def web_search(query: str) -> str: + return query + + async def dynamic_duplicate_tool() -> str: + return "tool" + + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] + + async def fake_get_tools(_self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: + assert http_client.is_closed is False + return [Tool(dynamic_duplicate_tool, name="web_search")] + + def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> object: + del model, tools, output_type + nonlocal create_agent_called + create_agent_called = True + raise AssertionError("create_agent should not be called when duplicate tool names are detected") + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + monkeypatch.setattr(DifyPluginToolsLayer, "get_tools", fake_get_tools) + monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent) + + static_tools_provider = LayerProvider.from_factory( + layer_type=StaticToolsTestLayer, + create=lambda _config: StaticToolsTestLayer(tool_entries=(web_search,)), + ) + layer_providers = (*create_default_layer_providers(), static_tools_provider) + + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig(prefix="system", user="hello"), + ), + RunLayerSpec(name="static-tools", type=cast(str, StaticToolsTestLayer.type_id)), + RunLayerSpec( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type="dify.plugin.llm", + deps={"execution_context": "execution_context"}, + config=DifyPluginLLMLayerConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="demo-model", + credentials={"api_key": "secret"}, + ), + ), + RunLayerSpec( + name="tools", + type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, + deps={"execution_context": "execution_context"}, + config=DifyPluginToolsLayerConfig( + tools=[ + DifyPluginToolConfig( + plugin_id="langgenius/tools", + provider="search", + tool_name="web_search", + credential_type="api-key", + parameters=_prepared_plugin_tool_parameters(), + parameters_json_schema=_prepared_plugin_tool_schema(), + ) + ] + ), + ), + ] + ) + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises( + AgentRunValidationError, + match="unique tool names across all layers, got duplicates: web_search", + ): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-static-dynamic-duplicate-tools", + plugin_daemon_http_client=client, + layer_providers=layer_providers, + ).run() + + asyncio.run(scenario()) + + assert create_agent_called is False + assert [event.type for event in sink.events["run-static-dynamic-duplicate-tools"]] == ["run_started", "run_failed"] + assert sink.statuses["run-static-dynamic-duplicate-tools"] == "failed" + + def test_runner_passes_temporary_system_prompt_prefix_without_history_layer(monkeypatch: pytest.MonkeyPatch) -> None: model = RecordingTestModel(custom_output_text="done") @@ -271,7 +627,7 @@ def test_runner_passes_temporary_system_prompt_prefix_without_history_layer(monk assert isinstance(terminal, RunSucceededEvent) assert [layer.name for layer in terminal.data.session_snapshot.layers] == [ "prompt", - "plugin", + "execution_context", DIFY_AGENT_MODEL_LAYER_ID, ] @@ -440,7 +796,7 @@ def test_runner_applies_on_exit_overrides_to_success_snapshot(monkeypatch: pytes assert isinstance(terminal, RunSucceededEvent) assert {layer.name: layer.lifecycle_state for layer in terminal.data.session_snapshot.layers} == { "prompt": LifecycleState.CLOSED, - "plugin": LifecycleState.SUSPENDED, + "execution_context": LifecycleState.SUSPENDED, DIFY_AGENT_MODEL_LAYER_ID: LifecycleState.CLOSED, } @@ -478,7 +834,12 @@ def test_runner_passes_output_layer_spec_to_agent_and_serializes_structured_resu ) ) sink = InMemoryRunEventSink() - expected_snapshot_layer_names = ["prompt", "plugin", DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID] + expected_snapshot_layer_names = [ + "prompt", + "execution_context", + DIFY_AGENT_MODEL_LAYER_ID, + DIFY_AGENT_OUTPUT_LAYER_ID, + ] async def scenario() -> None: async with httpx.AsyncClient() as client: @@ -682,15 +1043,16 @@ def test_runner_rejects_misnamed_output_layer_before_model_resolution(monkeypatc config=PromptLayerConfig(prefix="system", user="hello"), ), RunLayerSpec( - name="plugin", - type="dify.plugin", - config=DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai"), + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, type="dify.plugin.llm", - deps={"plugin": "plugin"}, + deps={"execution_context": "execution_context"}, config=DifyPluginLLMLayerConfig( + plugin_id="langgenius/openai", model_provider="openai", model="demo-model", credentials={"api_key": "secret"}, @@ -750,15 +1112,16 @@ def test_runner_rejects_multiple_output_layers_before_model_resolution(monkeypat config=PromptLayerConfig(prefix="system", user="hello"), ), RunLayerSpec( - name="plugin", - type="dify.plugin", - config=DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai"), + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, type="dify.plugin.llm", - deps={"plugin": "plugin"}, + deps={"execution_context": "execution_context"}, config=DifyPluginLLMLayerConfig( + plugin_id="langgenius/openai", model_provider="openai", model="demo-model", credentials={"api_key": "secret"}, @@ -840,15 +1203,16 @@ def test_runner_rejects_reserved_output_name_with_wrong_layer_type_before_model_ config=PromptLayerConfig(prefix="system", user="hello"), ), RunLayerSpec( - name="plugin", - type="dify.plugin", - config=DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai"), + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"), ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, type="dify.plugin.llm", - deps={"plugin": "plugin"}, + deps={"execution_context": "execution_context"}, config=DifyPluginLLMLayerConfig( + plugin_id="langgenius/openai", model_provider="openai", model="demo-model", credentials={"api_key": "secret"}, @@ -1042,7 +1406,7 @@ def test_runner_rejects_closed_session_snapshot_as_validation_error() -> None: runtime_state={}, ), LayerSessionSnapshot( - name="plugin", + name="execution_context", lifecycle_state=LifecycleState.NEW, runtime_state={}, ), diff --git a/dify-agent/tests/local/dify_agent/server/test_app.py b/dify-agent/tests/local/dify_agent/server/test_app.py index 73bfde69bd..a0415058d4 100644 --- a/dify-agent/tests/local/dify_agent/server/test_app.py +++ b/dify-agent/tests/local/dify_agent/server/test_app.py @@ -6,9 +6,9 @@ import pytest from fastapi.testclient import TestClient import dify_agent.server.app as app_module +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig +from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer from dify_agent.runtime.compositor_factory import DifyAgentLayerProvider -from dify_agent.layers.dify_plugin.configs import DifyPluginLayerConfig -from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer from dify_agent.server.app import create_app, create_plugin_daemon_http_client from dify_agent.server.settings import ServerSettings from dify_agent.storage.redis_run_store import RedisRunStore @@ -148,11 +148,15 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt assert scheduler.shutdown_grace_seconds == 5 layer_providers = scheduler.layer_providers assert isinstance(layer_providers, tuple) - plugin_provider = next(provider for provider in layer_providers if provider.type_id == "dify.plugin") - plugin_layer = plugin_provider.create_layer(DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="plugin-1")) - assert isinstance(plugin_layer, DifyPluginLayer) - assert plugin_layer.daemon_url == "http://plugin-daemon" - assert plugin_layer.daemon_api_key == "daemon-secret" + execution_context_provider = next( + provider for provider in layer_providers if provider.type_id == "dify.execution_context" + ) + execution_context_layer = execution_context_provider.create_layer( + DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run") + ) + assert isinstance(execution_context_layer, DifyExecutionContextLayer) + assert execution_context_layer.daemon_url == "http://plugin-daemon" + assert execution_context_layer.daemon_api_key == "daemon-secret" http_client = scheduler.plugin_daemon_http_client assert http_client is fake_http_client assert http_client.is_closed is False diff --git a/dify-agent/tests/local/dify_agent/server/test_runs_routes.py b/dify-agent/tests/local/dify_agent/server/test_runs_routes.py index bed7883170..a33590e208 100644 --- a/dify-agent/tests/local/dify_agent/server/test_runs_routes.py +++ b/dify-agent/tests/local/dify_agent/server/test_runs_routes.py @@ -1,7 +1,7 @@ from fastapi.testclient import TestClient from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID -from dify_agent.runtime.run_scheduler import RunRequestValidationError, SchedulerStoppingError +from dify_agent.runtime.run_scheduler import SchedulerStoppingError from dify_agent.server.routes.runs import create_runs_router from dify_agent.server.schemas import RunRecord @@ -9,14 +9,14 @@ from dify_agent.server.schemas import RunRecord class FakeScheduler: async def create_run(self, request: object) -> object: del request - raise RunRequestValidationError("run.user_prompts must not be empty") + return RunRecord(run_id="run-1", status="running") class FakeStore: pass -def test_create_run_rejects_effectively_blank_user_prompt_list() -> None: +def test_create_run_accepts_effectively_blank_user_prompt_list() -> None: from fastapi import FastAPI app = FastAPI() @@ -35,8 +35,8 @@ def test_create_run_rejects_effectively_blank_user_prompt_list() -> None: }, ) - assert response.status_code == 422 - assert response.json()["detail"] == "run.user_prompts must not be empty" + assert response.status_code == 202 + assert response.json() == {"run_id": "run-1", "status": "running"} def test_create_run_returns_running_from_scheduler() -> None: @@ -104,15 +104,16 @@ def test_create_run_accepts_valid_full_plugin_graph() -> None: "layers": [ {"name": "prompt", "type": "plain.prompt", "config": {"user": "hello"}}, { - "name": "plugin-renamed", - "type": "dify.plugin", - "config": {"tenant_id": "tenant-1", "plugin_id": "langgenius/openai"}, + "name": "execution-context-renamed", + "type": "dify.execution_context", + "config": {"tenant_id": "tenant-1", "invoke_from": "workflow_run"}, }, { "name": DIFY_AGENT_MODEL_LAYER_ID, "type": "dify.plugin.llm", - "deps": {"plugin": "plugin-renamed"}, + "deps": {"execution_context": "execution-context-renamed"}, "config": { + "plugin_id": "langgenius/openai", "model_provider": "openai", "model": "gpt-4o-mini", "credentials": {"api_key": "secret"}, @@ -128,17 +129,12 @@ def test_create_run_accepts_valid_full_plugin_graph() -> None: assert response.json() == {"run_id": "run-1", "status": "running"} -def test_create_run_rejects_unknown_layer_exit_signal_before_scheduling() -> None: +def test_create_run_accepts_unknown_layer_exit_signal_request() -> None: from fastapi import FastAPI - class UnknownSignalScheduler: - async def create_run(self, request: object) -> RunRecord: - del request - raise RunRequestValidationError("on_exit.layers references unknown layer ids: missing.") - app = FastAPI() app.include_router( - create_runs_router(lambda: FakeStore(), lambda: UnknownSignalScheduler()) # pyright: ignore[reportArgumentType] + create_runs_router(lambda: FakeStore(), lambda: FakeScheduler()) # pyright: ignore[reportArgumentType] ) client = TestClient(app) @@ -153,21 +149,16 @@ def test_create_run_rejects_unknown_layer_exit_signal_before_scheduling() -> Non }, ) - assert response.status_code == 422 - assert "missing" in response.json()["detail"] + assert response.status_code == 202 + assert response.json() == {"run_id": "run-1", "status": "running"} -def test_create_run_rejects_closed_session_snapshot_with_422() -> None: +def test_create_run_accepts_closed_session_snapshot_request() -> None: from fastapi import FastAPI - class ClosedSnapshotScheduler: - async def create_run(self, request: object) -> RunRecord: - del request - raise RunRequestValidationError("Layer 'prompt' is closed; CLOSED snapshots cannot be entered.") - app = FastAPI() app.include_router( - create_runs_router(lambda: FakeStore(), lambda: ClosedSnapshotScheduler()) # pyright: ignore[reportArgumentType] + create_runs_router(lambda: FakeStore(), lambda: FakeScheduler()) # pyright: ignore[reportArgumentType] ) client = TestClient(app) @@ -191,8 +182,8 @@ def test_create_run_rejects_closed_session_snapshot_with_422() -> None: }, ) - assert response.status_code == 422 - assert "CLOSED snapshots cannot be entered" in response.json()["detail"] + assert response.status_code == 202 + assert response.json() == {"run_id": "run-1", "status": "running"} def test_create_run_returns_503_when_scheduler_is_stopping() -> None: diff --git a/dify-agent/tests/local/dify_agent/test_import_boundaries.py b/dify-agent/tests/local/dify_agent/test_import_boundaries.py index 7ff55b167b..92a3d45f2a 100644 --- a/dify-agent/tests/local/dify_agent/test_import_boundaries.py +++ b/dify-agent/tests/local/dify_agent/test_import_boundaries.py @@ -79,8 +79,9 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> blocked_imports=[ "anthropic", "dify_agent.adapters.llm", + "dify_agent.layers.execution_context.layer", "dify_agent.layers.dify_plugin.llm_layer", - "dify_agent.layers.dify_plugin.plugin_layer", + "dify_agent.layers.dify_plugin.tools_layer", "dify_agent.layers.output.output_layer", "dify_agent.runtime", "dify_agent.server", @@ -91,10 +92,16 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> "pydantic_settings", "redis", ], - imports=["dify_agent.protocol", "dify_agent.layers.dify_plugin", "dify_agent.layers.output"], + imports=[ + "dify_agent.protocol", + "dify_agent.layers.execution_context", + "dify_agent.layers.dify_plugin", + "dify_agent.layers.output", + ], assertions=[ "assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')", - "assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LAYER_TYPE_ID', 'DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginLayerConfig']", + "assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig']", + "assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']", "assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']", ], )