mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:32:01 +08:00
feat(dify-agent): sync agent progress (#36633)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
884e2b864b
commit
0f06aa2fdd
2
.gitignore
vendored
2
.gitignore
vendored
@ -257,5 +257,5 @@ scripts/stress-test/reports/
|
||||
|
||||
# Code Agent Folder
|
||||
.qoder/*
|
||||
.context/*
|
||||
.context/
|
||||
.eslintcache
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
@ -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"],
|
||||
# },
|
||||
# )
|
||||
# ]
|
||||
# ),
|
||||
# ),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@ -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"],
|
||||
# },
|
||||
# )
|
||||
# ]
|
||||
# ),
|
||||
# ),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"]
|
||||
333
dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py
Normal file
333
dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py
Normal file
@ -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",
|
||||
]
|
||||
341
dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py
Normal file
341
dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py
Normal file
@ -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"]
|
||||
@ -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",
|
||||
]
|
||||
@ -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",
|
||||
]
|
||||
95
dify-agent/src/dify_agent/layers/execution_context/layer.py
Normal file
95
dify-agent/src/dify_agent/layers/execution_context/layer.py
Normal file
@ -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"]
|
||||
72
dify-agent/src/dify_agent/plugin_daemon_transport.py
Normal file
72
dify-agent/src/dify_agent/plugin_daemon_transport.py
Normal file
@ -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",
|
||||
]
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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.
|
||||
"""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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",
|
||||
}
|
||||
)
|
||||
@ -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())
|
||||
@ -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"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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={},
|
||||
),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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']",
|
||||
],
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user