Merge branch 'main' into feat/model-type-migration-script

This commit is contained in:
Xiyuan Chen
2026-05-25 21:21:05 -07:00
committed by GitHub
145 changed files with 4114 additions and 2197 deletions

2
.gitignore vendored
View File

@ -257,5 +257,5 @@ scripts/stress-test/reports/
# Code Agent Folder
.qoder/*
.context/*
.context/
.eslintcache

View File

@ -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",

View File

@ -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,

View File

@ -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(

View File

@ -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,

View File

@ -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,

View File

@ -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",
)

View File

@ -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",
)

View File

@ -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",
)

View File

@ -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"

View File

@ -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():

View File

@ -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"

View File

@ -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.

View File

@ -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,

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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"],
# },
# )
# ]
# ),
# ),
],
),
)

View File

@ -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"],
# },
# )
# ]
# ),
# ),
],
),
)

View File

@ -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:

View File

@ -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":

View File

@ -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",
]

View File

@ -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",
]

View File

@ -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,

View File

@ -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"]

View 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",
]

View 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"]

View File

@ -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",
]

View File

@ -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",
]

View 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"]

View 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",
]

View File

@ -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",

View File

@ -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",

View File

@ -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),
)

View File

@ -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"]

View File

@ -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"]

View File

@ -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.
"""

View File

@ -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)

View File

@ -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",
}
)

View File

@ -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())

View File

@ -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",
}
)

View File

@ -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())

View File

@ -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"},
}
)

View File

@ -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())

View File

@ -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={},
),

View File

@ -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

View File

@ -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:

View File

@ -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']",
],
)

View File

@ -1757,14 +1757,6 @@
"count": 1
}
},
"web/app/components/base/textarea/index.stories.tsx": {
"no-console": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/voice-input/__tests__/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 3

View File

@ -34,6 +34,7 @@ import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import { Textarea } from '@langgenius/dify-ui/textarea'
import '@langgenius/dify-ui/styles.css' // once, in the app root
```
@ -41,17 +42,17 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
## Primitives
| Category | Subpath | Notes |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
| Controls | `./segmented-control` | SegmentedControl for mode, filter, and view selection. |
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
| Navigation | `./pagination`, `./tabs` | Pagination for page navigation; Tabs for panels. |
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
| Category | Subpath | Notes |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
| Controls | `./segmented-control` | SegmentedControl for mode, filter, and view selection. |
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./textarea`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
| Navigation | `./pagination`, `./tabs` | Pagination for page navigation; Tabs for panels. |
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
Utilities:
@ -72,7 +73,7 @@ Use `Form` for the submit boundary. It renders a native `<form>`, preserves Ente
Use `FieldRoot` for each standalone named field. A field must have a stable `name`, a label relationship, and either a `FieldControl` or another control that participates in the same Base UI field context. Prefer a visible label for normal form rows; when the surrounding UI already supplies the visible text, use the matching label primitive visually hidden or put `aria-label` on the actual interactive control. `FieldDescription` and `FieldError` provide the message relationships that screen readers need, while the Dify wrapper adds the default Form Input Set styling from the design system.
Choose the label primitive by the control semantics. Text-like inputs, input-based `Combobox` / `Autocomplete`, single `Checkbox` / `Radio`, `Switch`, and `NumberField` use `FieldLabel`. Trigger-based `Select` fields use `SelectLabel`; `Slider` fields use `SliderLabel`, with per-thumb `aria-label` only when the thumbs need distinct names. `SelectGroupLabel` and `AutocompleteGroupLabel` only label grouped options inside their popup content; they are not field labels.
Choose the label primitive by the control semantics. Text-like inputs, `Textarea`, input-based `Combobox` / `Autocomplete`, single `Checkbox` / `Radio`, `Switch`, and `NumberField` use `FieldLabel`. Trigger-based `Select` fields use `SelectLabel`; `Slider` fields use `SliderLabel`, with per-thumb `aria-label` only when the thumbs need distinct names. `SelectGroupLabel` and `AutocompleteGroupLabel` only label grouped options inside their popup content; they are not field labels.
Use `FieldsetRoot` and `FieldsetLegend` when one field is represented by a group of related controls, such as checkbox groups, radio groups, multi-thumb sliders, or a section that combines several inputs. For checkbox and radio groups, wrap each option with `FieldItem` and give each option its own label:

View File

@ -129,6 +129,10 @@
"types": "./src/tabs/index.tsx",
"import": "./src/tabs/index.tsx"
},
"./textarea": {
"types": "./src/textarea/index.tsx",
"import": "./src/textarea/index.tsx"
},
"./toast": {
"types": "./src/toast/index.tsx",
"import": "./src/toast/index.tsx"

View File

@ -0,0 +1,187 @@
import type { FocusEvent } from 'react'
import { render } from 'vitest-browser-react'
import {
FieldDescription,
FieldError,
FieldLabel,
FieldRoot,
} from '../../field'
import { Form } from '../../form'
import { Textarea } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
const setTextareaValue = (element: HTMLElement | SVGElement, value: string) => {
const textarea = asHTMLElement(element) as HTMLTextAreaElement
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
valueSetter?.call(textarea, value)
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
describe('Textarea', () => {
it('should render a labelled textarea through Base UI Field.Control', async () => {
const screen = await render(
<FieldRoot name="description">
<FieldLabel>Description</FieldLabel>
<Textarea defaultValue="A workspace for support automation." />
<FieldDescription>Shown to workspace members.</FieldDescription>
</FieldRoot>,
)
const textarea = screen.getByRole('textbox', { name: 'Description' })
await expect.element(textarea).toHaveValue('A workspace for support automation.')
await expect.element(textarea).toHaveAccessibleDescription('Shown to workspace members.')
await expect.element(textarea).toHaveClass('min-h-20', 'overflow-auto', 'rounded-lg', 'system-sm-regular')
expect(asHTMLElement(textarea.element()).tagName).toBe('TEXTAREA')
})
it('should apply size variants and custom classes', async () => {
const screen = await render(
<label>
Prompt
<Textarea size="large" className="resize-none" />
</label>,
)
await expect.element(screen.getByRole('textbox', { name: 'Prompt' })).toHaveClass(
'rounded-[10px]',
'px-4',
'py-2',
'system-md-regular',
'resize-none',
)
})
it('should call onValueChange and stay controlled until value changes', async () => {
const onValueChange = vi.fn()
const screen = await render(
<label>
Notes
<Textarea value="" onValueChange={onValueChange} />
</label>,
)
const textarea = screen.getByRole('textbox', { name: 'Notes' })
setTextareaValue(textarea.element(), 'a')
expect(onValueChange).toHaveBeenCalledWith('a', expect.any(Object))
await expect.element(textarea).toHaveValue('')
await screen.rerender(
<label>
Notes
<Textarea value="a" onValueChange={onValueChange} />
</label>,
)
await expect.element(screen.getByRole('textbox', { name: 'Notes' })).toHaveValue('a')
})
it('should submit valid values and show validation errors through Base UI Form', async () => {
const onFormSubmit = vi.fn()
const screen = await render(
<Form aria-label="dataset form" onFormSubmit={onFormSubmit}>
<FieldRoot name="summary">
<FieldLabel>Summary</FieldLabel>
<Textarea required minLength={10} />
<FieldError match="valueMissing">Summary is required.</FieldError>
<FieldError match="tooShort">Summary is too short.</FieldError>
</FieldRoot>
<button type="submit">Save</button>
</Form>,
)
const saveButton = asHTMLElement(screen.getByRole('button', { name: 'Save' }).element())
saveButton.click()
await vi.waitFor(async () => {
await expect.element(screen.getByText('Summary is required.')).toBeInTheDocument()
await expect.element(screen.getByRole('textbox', { name: 'Summary' })).toHaveAttribute('aria-invalid', 'true')
})
expect(onFormSubmit).not.toHaveBeenCalled()
await screen.rerender(
<Form aria-label="dataset form" onFormSubmit={onFormSubmit}>
<FieldRoot name="summary">
<FieldLabel>Summary</FieldLabel>
<Textarea key="valid-summary" required minLength={10} defaultValue="Long enough summary" />
<FieldError match="valueMissing">Summary is required.</FieldError>
<FieldError match="tooShort">Summary is too short.</FieldError>
</FieldRoot>
<button type="submit">Save</button>
</Form>,
)
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
expect(onFormSubmit).toHaveBeenCalledTimes(1)
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({ summary: 'Long enough summary' })
})
it('should pass maxLength to the textarea without rendering a counter', async () => {
const screen = await render(
<label>
Release notes
<Textarea defaultValue="Draft" maxLength={20} />
</label>,
)
const textarea = screen.getByRole('textbox', { name: 'Release notes' })
await expect.element(textarea).toHaveAttribute('maxLength', '20')
expect(screen.container.textContent).not.toContain('5/20')
})
it('should route field props through Base UI Field.Control and textarea-only props to textarea', async () => {
const onFormSubmit = vi.fn()
const onBlur = vi.fn((event: FocusEvent<HTMLTextAreaElement>) => {
expect(event.currentTarget.tagName).toBe('TEXTAREA')
})
const screen = await render(
<Form aria-label="profile form" onFormSubmit={onFormSubmit}>
<FieldRoot name="profileSummary">
<FieldLabel>Profile summary</FieldLabel>
<Textarea
id="profile-summary"
name="ignoredControlName"
defaultValue="Long enough summary"
rows={6}
cols={40}
wrap="soft"
maxLength={80}
onBlur={onBlur}
/>
</FieldRoot>
<FieldRoot disabled>
<FieldLabel>Disabled note</FieldLabel>
<Textarea name="disabledNote" defaultValue="Disabled value" />
</FieldRoot>
<button type="submit">Save</button>
</Form>,
)
const profileSummary = screen.getByRole('textbox', { name: 'Profile summary' })
expect(
asHTMLElement(screen.getByText('Profile summary').element()).getAttribute('for'),
).toBe('profile-summary')
await expect.element(profileSummary).toHaveAttribute('id', 'profile-summary')
await expect.element(profileSummary).toHaveAttribute('name', 'profileSummary')
await expect.element(profileSummary).toHaveAttribute('rows', '6')
await expect.element(profileSummary).toHaveAttribute('cols', '40')
await expect.element(profileSummary).toHaveAttribute('wrap', 'soft')
await expect.element(profileSummary).toHaveAttribute('maxLength', '80')
await expect.element(screen.getByRole('textbox', { name: 'Disabled note' })).toBeDisabled()
asHTMLElement(profileSummary.element()).focus()
const saveButton = asHTMLElement(screen.getByRole('button', { name: 'Save' }).element())
saveButton.focus()
expect(onBlur).toHaveBeenCalledTimes(1)
saveButton.click()
expect(onFormSubmit).toHaveBeenCalledTimes(1)
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({
profileSummary: 'Long enough summary',
})
expect(onFormSubmit.mock.calls[0]?.[0]).not.toHaveProperty('ignoredControlName')
expect(onFormSubmit.mock.calls[0]?.[0]).not.toHaveProperty('disabledNote')
})
})

View File

@ -0,0 +1,193 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { useState } from 'react'
import { Button } from '../button'
import {
FieldDescription,
FieldError,
FieldLabel,
FieldRoot,
} from '../field'
import { Form } from '../form'
import { Textarea } from './index'
const meta = {
title: 'Base/Form/Textarea',
component: Textarea,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Multiline text control built on Base UI Field.Control. Use it with FieldRoot for labelled, described, and validated form fields.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Textarea>
export default meta
type Story = StoryObj<typeof meta>
export const Basic: Story = {
render: () => (
<div className="w-80">
<label htmlFor="workspace-description" className="mb-1 block w-fit py-1 text-text-secondary system-sm-medium">
Workspace description
</label>
<Textarea
id="workspace-description"
name="workspaceDescription"
placeholder="Describe how this workspace is used..."
/>
</div>
),
}
export const Sizes: Story = {
render: () => (
<div className="grid w-80 gap-3">
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="small-textarea">
Small
<Textarea id="small-textarea" size="small" name="smallTextarea" placeholder="Short note..." rows={3} />
</label>
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="medium-textarea">
Medium
<Textarea id="medium-textarea" name="mediumTextarea" placeholder="Add context..." rows={3} />
</label>
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="large-textarea">
Large
<Textarea id="large-textarea" size="large" name="largeTextarea" placeholder="Write a longer instruction..." rows={3} />
</label>
</div>
),
}
export const States: Story = {
render: () => (
<div className="grid w-80 gap-3">
<FieldRoot name="placeholderState">
<FieldLabel>Placeholder</FieldLabel>
<Textarea placeholder="Add a description..." rows={3} />
</FieldRoot>
<FieldRoot name="filledState">
<FieldLabel>Filled</FieldLabel>
<Textarea defaultValue="Use this dataset for support articles and product FAQs." rows={3} />
</FieldRoot>
<FieldRoot name="invalidState" invalid>
<FieldLabel>Invalid</FieldLabel>
<Textarea defaultValue="Too short" rows={3} />
<FieldError match>Use at least 20 characters.</FieldError>
</FieldRoot>
<FieldRoot name="disabledState">
<FieldLabel>Disabled</FieldLabel>
<Textarea disabled placeholder="Editing is unavailable..." rows={3} />
</FieldRoot>
<FieldRoot name="readonlyState">
<FieldLabel>Read-only</FieldLabel>
<Textarea readOnly defaultValue="Generated from the published workflow configuration." rows={3} />
</FieldRoot>
</div>
),
}
const FormDemo = () => {
const [savedDescription, setSavedDescription] = useState<string | null>(null)
return (
<Form
aria-label="Dataset settings"
className="grid w-80 gap-4"
onFormSubmit={(values) => {
setSavedDescription(String(values.description ?? ''))
}}
>
<FieldRoot name="description">
<FieldLabel>Description</FieldLabel>
<Textarea
required
minLength={20}
maxLength={160}
placeholder="Describe what this dataset contains..."
rows={4}
className="resize-y"
/>
<FieldDescription>Shown to teammates when they choose a knowledge source.</FieldDescription>
<FieldError match="valueMissing">Description is required.</FieldError>
<FieldError match="tooShort">Use at least 20 characters.</FieldError>
</FieldRoot>
<div className="flex justify-end">
<Button type="submit" variant="primary">Save Settings</Button>
</div>
{savedDescription && (
<div className="rounded-lg bg-background-section px-3 py-2 text-text-secondary system-xs-regular">
Saved:
{' '}
{savedDescription}
</div>
)}
</Form>
)
}
export const WithField: Story = {
render: () => <FormDemo />,
}
const ControlledDemo = () => {
const [value, setValue] = useState('Summarize customer feedback into actionable product themes.')
return (
<FieldRoot name="prompt">
<FieldLabel>Prompt</FieldLabel>
<Textarea
value={value}
onValueChange={nextValue => setValue(nextValue)}
rows={4}
className="resize-y"
/>
<FieldDescription>The saved value is updated from the controlled state.</FieldDescription>
</FieldRoot>
)
}
export const Controlled: Story = {
render: () => (
<div className="w-80">
<ControlledDemo />
</div>
),
}
const CharacterCounterDemo = () => {
const maxLength = 120
const [value, setValue] = useState('Summarize customer feedback into actionable product themes.')
return (
<FieldRoot name="limitedPrompt">
<FieldLabel>Prompt</FieldLabel>
<div className="relative">
<Textarea
value={value}
onValueChange={nextValue => setValue(nextValue)}
maxLength={maxLength}
rows={4}
className="resize-y pb-8"
/>
<div className="pointer-events-none absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 text-text-quaternary system-xs-medium">
<span>{value.length}</span>
/
<span className="text-text-tertiary">{maxLength}</span>
</div>
</div>
<FieldDescription>Character counters are composed at the usage site when the workflow needs one.</FieldDescription>
</FieldRoot>
)
}
export const WithCharacterCounter: Story = {
render: () => (
<div className="w-80">
<CharacterCounterDemo />
</div>
),
}

View File

@ -0,0 +1,103 @@
'use client'
import type { Field as BaseFieldNS } from '@base-ui/react/field'
import type { VariantProps } from 'class-variance-authority'
import type { ComponentPropsWithRef } from 'react'
import { Field as BaseField } from '@base-ui/react/field'
import { cva } from 'class-variance-authority'
import { cn } from '../cn'
const textareaVariants = cva(
[
'min-h-20 w-full appearance-none overflow-auto border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]',
'placeholder:text-components-input-text-placeholder',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive',
'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none',
'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled',
'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled',
'motion-reduce:transition-none',
],
{
variants: {
size: {
small: 'rounded-md px-2 py-1 system-xs-regular',
medium: 'rounded-lg px-3 py-2 system-sm-regular',
large: 'rounded-[10px] px-4 py-2 system-md-regular',
},
},
defaultVariants: {
size: 'medium',
},
},
)
type TextareaValue = string | number
export type TextareaSize = NonNullable<VariantProps<typeof textareaVariants>['size']>
export type TextareaChangeEventDetails = BaseFieldNS.Control.ChangeEventDetails
type TextareaOnValueChange = (value: string, eventDetails: TextareaChangeEventDetails) => void
type ControlledTextareaProps = {
value: TextareaValue
defaultValue?: never
onValueChange: TextareaOnValueChange
}
type UncontrolledTextareaProps = {
value?: never
defaultValue?: TextareaValue
onValueChange?: TextareaOnValueChange
}
type TextareaNativeProps = ComponentPropsWithRef<'textarea'>
type TextareaOnlyProps = Pick<TextareaNativeProps, 'cols' | 'rows' | 'wrap'>
type TextareaElementProps = Omit<
TextareaNativeProps,
'children' | 'className' | 'cols' | 'defaultValue' | 'onChange' | 'rows' | 'size' | 'value' | 'wrap'
>
type TextareaControlProps = ControlledTextareaProps | UncontrolledTextareaProps
type TextareaVariantProps = VariantProps<typeof textareaVariants>
type FieldControlTextareaProps = Omit<
BaseFieldNS.Control.Props,
'className' | 'defaultValue' | 'onValueChange' | 'render' | 'value'
>
export type TextareaProps
= TextareaElementProps
& TextareaOnlyProps
& TextareaControlProps
& TextareaVariantProps
& {
children?: never
className?: string
}
export function Textarea({
className,
cols,
defaultValue,
onValueChange,
ref,
rows,
size = 'medium',
value,
wrap,
...controlProps
}: TextareaProps) {
// Base UI types Field.Control as an input even when render replaces it with a textarea.
const fieldControlProps = controlProps as FieldControlTextareaProps
return (
<BaseField.Control
{...fieldControlProps}
className={cn(textareaVariants({ size }), className)}
defaultValue={defaultValue}
onValueChange={onValueChange}
ref={ref}
render={<textarea cols={cols} rows={rows} wrap={wrap} />}
value={value}
/>
)
}

View File

@ -493,8 +493,8 @@ describe('Capacity Full Components Integration', () => {
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
// Should show usage/total fraction "5/5"
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
// Should have a meter rendered
expect(screen.getByRole('meter')).toBeInTheDocument()
// Should have an accessible meter rendered
expect(screen.getByRole('meter', { name: /usagePage\.buildApps/i })).toBeInTheDocument()
})
it('should display upgrade tip and upgrade button for professional plan', () => {

View File

@ -1,10 +1,10 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
import { useAppContext } from '@/context/app-context'
import { useRouter } from '@/next/navigation'
import { useLogout } from '@/service/use-common'
@ -63,11 +63,12 @@ export default function FeedBack(props: DeleteAccountProps) {
</DialogTitle>
<label className="mt-3 mb-1 flex items-center system-sm-semibold text-text-secondary">{t('account.feedbackLabel', { ns: 'common' })}</label>
<Textarea
aria-label={t('account.feedbackLabel', { ns: 'common' }) as string}
rows={6}
value={userFeedback}
placeholder={t('account.feedbackPlaceholder', { ns: 'common' }) as string}
onChange={(e) => {
setUserFeedback(e.target.value)
onValueChange={(value) => {
setUserFeedback(value)
}}
/>
<div className="mt-3 flex w-full flex-col gap-2">

View File

@ -1,9 +1,9 @@
'use client'
import type { FC } from 'react'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
import Textarea from '@/app/components/base/textarea'
export enum EditItemType {
Query = 'query',
@ -33,8 +33,9 @@ const EditItem: FC<Props> = ({
<div className="grow">
<div className="mb-1 system-xs-semibold text-text-primary">{name}</div>
<Textarea
aria-label={name}
value={content}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
onValueChange={value => onChange(value)}
placeholder={placeholder}
autoFocus
/>

View File

@ -2,12 +2,12 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { RiDeleteBinLine, RiEditFill, RiEditLine } from '@remixicon/react'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
import Textarea from '@/app/components/base/textarea'
export enum EditItemType {
Query = 'query',
@ -130,8 +130,9 @@ const EditItem: FC<Props> = ({
<div className="mt-3">
<EditTitle title={editTitle} />
<Textarea
aria-label={editTitle}
value={newContent}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewContent(e.target.value)}
onValueChange={value => setNewContent(value)}
placeholder={placeholder}
autoFocus
/>

View File

@ -3,12 +3,12 @@ import type { VersionHistory } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from '../../base/textarea'
type VersionInfoModalProps = {
isOpen: boolean
@ -57,8 +57,8 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
onClose()
}
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setReleaseNotes(e.target.value)
const handleDescriptionChange = useCallback((value: string) => {
setReleaseNotes(value)
}, [])
return (
@ -95,17 +95,16 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
onValueChange={setTitle}
/>
</FieldRoot>
<div className="flex flex-col gap-y-1">
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">
<FieldRoot name="releaseNotes" invalid={releaseNotesError} className="gap-y-1">
<FieldLabel className="flex h-6 items-center py-0 system-sm-semibold text-text-secondary">
{t('versionHistory.editField.releaseNotes', { ns: 'workflow' })}
</div>
</FieldLabel>
<Textarea
value={releaseNotes}
placeholder={`${t('versionHistory.releaseNotesPlaceholder', { ns: 'workflow' })}${t('panel.optional', { ns: 'workflow' })}`}
onChange={handleDescriptionChange}
destructive={releaseNotesError}
onValueChange={handleDescriptionChange}
/>
</div>
</FieldRoot>
</div>
<div className="flex justify-end p-6 pt-5">
<div className="flex items-center gap-x-3">

View File

@ -13,12 +13,12 @@ import {
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { Trans } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
@ -121,8 +121,9 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
{type === InputVarType.paragraph && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Textarea
aria-label={t('variableConfig.defaultValue', { ns: 'appDebug' })}
value={String(tempPayload.default ?? '')}
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
onValueChange={value => onPayloadChange('default')(value || undefined)}
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
/>
</Field>

View File

@ -1,11 +1,11 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
import Textarea from '@/app/components/base/textarea'
const i18nPrefix = 'generate'
@ -40,10 +40,11 @@ const IdeaOutput: FC<Props> = ({
</div>
{!isFoldIdeaOutput && (
<Textarea
aria-label={t(`${i18nPrefix}.idealOutput`, { ns: 'appDebug' })}
className="h-[80px]"
placeholder={t(`${i18nPrefix}.idealOutputPlaceholder`, { ns: 'appDebug' })}
value={value}
onChange={e => onChange(e.target.value)}
onValueChange={value => onChange(value)}
/>
)}
</div>

View File

@ -4,13 +4,13 @@ import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { isEqual } from 'es-toolkit/predicate'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import IndexMethod from '@/app/components/datasets/settings/index-method'
@ -224,8 +224,9 @@ const SettingsModal: FC<SettingsModalProps> = ({
</div>
<div className="w-full">
<Textarea
aria-label={t('form.desc', { ns: 'datasetSettings' })}
value={localeCurrentDataset.description || ''}
onChange={e => handleValueChange('description', e.target.value)}
onValueChange={value => handleValueChange('description', value)}
className="resize-none"
placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''}
/>

View File

@ -84,25 +84,6 @@ vi.mock('@langgenius/dify-ui/select', async () => {
}
})
vi.mock('@/app/components/base/textarea', () => ({
default: ({ value, onChange, placeholder, readOnly, className }: {
value: string
onChange: (e: { target: { value: string } }) => void
placeholder?: string
readOnly?: boolean
className?: string
}) => (
<textarea
data-testid={`textarea-${placeholder}`}
value={value}
onChange={onChange}
placeholder={placeholder}
readOnly={readOnly}
className={className}
/>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({
default: ({ name, value, required, onChange, readonly }: {
name: string
@ -223,7 +204,7 @@ describe('ChatUserInput', () => {
}))
render(<ChatUserInput inputs={{}} />)
expect(screen.getByTestId('textarea-Description')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument()
})
it('should render select input type', () => {
@ -275,7 +256,7 @@ describe('ChatUserInput', () => {
render(<ChatUserInput inputs={{}} />)
expect(screen.getByTestId('input-Name')).toBeInTheDocument()
expect(screen.getByTestId('textarea-Description')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument()
expect(screen.getByTestId('select-input')).toBeInTheDocument()
})
@ -334,7 +315,7 @@ describe('ChatUserInput', () => {
}))
render(<ChatUserInput inputs={{ desc: 'Long text here' }} />)
expect(screen.getByTestId('textarea-Description')).toHaveValue('Long text here')
expect(screen.getByRole('textbox', { name: 'Description' })).toHaveValue('Long text here')
})
it('should display existing input values for number type', () => {
@ -418,7 +399,7 @@ describe('ChatUserInput', () => {
}))
render(<ChatUserInput inputs={{}} />)
fireEvent.change(screen.getByTestId('textarea-Description'), { target: { value: 'New Description' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), { target: { value: 'New Description' } })
expect(mockSetInputs).toHaveBeenCalledWith({ desc: 'New Description' })
})
@ -526,7 +507,7 @@ describe('ChatUserInput', () => {
}))
render(<ChatUserInput inputs={{}} />)
expect(screen.getByTestId('textarea-Description')).toHaveAttribute('readonly')
expect(screen.getByRole('textbox', { name: 'Description' })).toHaveAttribute('readonly')
})
it('should disable select when readonly is true', () => {

View File

@ -1,12 +1,12 @@
import type { Inputs } from '@/models/debug'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import ConfigContext from '@/context/debug-configuration'
@ -94,9 +94,10 @@ const ChatUserInput = ({
{type === 'paragraph' && (
<Textarea
className="h-[120px] grow"
aria-label={name || key}
placeholder={name}
value={inputs[key] ? `${inputs[key]}` : ''}
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
onValueChange={(value) => { handleInputValueChange(key, value) }}
readOnly={readonly}
/>
)}

View File

@ -5,6 +5,7 @@ import type { VisionFile, VisionSettings } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiArrowDownSLine,
@ -19,7 +20,6 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import ConfigContext from '@/context/debug-configuration'
import { AppModeEnum, ModelModeType } from '@/types/app'
@ -151,10 +151,11 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
)}
{type === 'paragraph' && (
<Textarea
aria-label={name}
className="h-[120px] grow"
placeholder={name}
value={inputs[key] ? `${inputs[key]}` : ''}
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
onValueChange={(value) => { handleInputValueChange(key, value) }}
readOnly={readonly}
/>
)}

View File

@ -4,6 +4,7 @@ import type { AppIconSelection } from '../../base/app-icon-picker'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
@ -13,7 +14,6 @@ import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider'
import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
@ -241,10 +241,11 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
</span>
</div>
<Textarea
aria-label={t('newApp.captionDescription', { ns: 'app' })}
className="resize-none"
placeholder={t('newApp.appDescriptionPlaceholder', { ns: 'app' }) || ''}
value={description}
onChange={e => setDescription(e.target.value)}
onValueChange={value => setDescription(value)}
/>
</div>
</div>

View File

@ -8,6 +8,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Switch } from '@langgenius/dify-ui/switch'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import * as React from 'react'
@ -18,7 +19,6 @@ import AppIconPicker from '@/app/components/base/app-icon-picker'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import { PremiumBadgeButton } from '@/app/components/base/premium-badge'
import Textarea from '@/app/components/base/textarea'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
@ -289,9 +289,10 @@ const SettingsModal: FC<ISettingsModalProps> = ({
<div className="relative">
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}</div>
<Textarea
aria-label={t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}
className="mt-1"
value={inputInfo.desc}
onChange={e => onDesChange(e.target.value)}
onValueChange={onDesChange}
placeholder={t(`${prefixSettings}.webDescPlaceholder`, { ns: 'appOverview' }) as string}
/>
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.webDescTip`, { ns: 'appOverview' })}</p>
@ -464,9 +465,10 @@ const SettingsModal: FC<ISettingsModalProps> = ({
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}</div>
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.more.customDisclaimerTip`, { ns: 'appOverview' })}</p>
<Textarea
aria-label={t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}
className="mt-1"
value={inputInfo.customDisclaimer}
onChange={onChange('customDisclaimer')}
onValueChange={value => setInputInfo(item => ({ ...item, customDisclaimer: value }))}
placeholder={t(`${prefixSettings}.more.customDisclaimerPlaceholder`, { ns: 'appOverview' }) as string}
/>
</div>

View File

@ -7,8 +7,8 @@ import {
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { InputVarType } from '@/app/components/workflow/types'
@ -74,7 +74,7 @@ const WorkflowHiddenInputFields = ({
<Textarea
id={fieldId}
value={typeof fieldValue === 'string' ? fieldValue : ''}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onValueChange(variable.variable, event.target.value)}
onValueChange={value => onValueChange(variable.variable, value)}
placeholder={label}
maxLength={variable.max_length}
className="min-h-24"

View File

@ -1,10 +1,10 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
@ -71,8 +71,9 @@ const InputsFormContent = ({ showTip }: Props) => {
)}
{form.type === InputVarType.paragraph && (
<Textarea
aria-label={form.label}
value={inputsFormValue?.[form.variable] || ''}
onChange={e => handleFormChange(form.variable, e.target.value)}
onValueChange={value => handleFormChange(form.variable, value)}
placeholder={form.label}
/>
)}

View File

@ -1,8 +1,8 @@
import type { ContentItemProps } from './type'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useMemo } from 'react'
import { Markdown } from '@/app/components/base/markdown'
import Textarea from '@/app/components/base/textarea'
const ContentItem = ({
content,
@ -42,9 +42,10 @@ const ContentItem = ({
<div className="py-3">
{formInputField.type === 'paragraph' && (
<Textarea
aria-label={fieldName}
className="h-[104px] sm:text-xs"
value={inputs[fieldName]!}
onChange={(e) => { onInputChange(fieldName, e.target.value) }}
onValueChange={(value) => { onInputChange(fieldName, value) }}
data-testid="content-item-textarea"
/>
)}

View File

@ -6,6 +6,7 @@ import type {
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import copy from 'copy-to-clipboard'
@ -21,7 +22,6 @@ import ActionButton, { ActionButtonState } from '@/app/components/base/action-bu
import Log from '@/app/components/base/chat/chat/log'
import AnnotationCtrlButton from '@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button'
import NewAudioButton from '@/app/components/base/new-audio-button'
import Textarea from '@/app/components/base/textarea'
import { useChatContext } from '../context'
type OperationProps = {
@ -394,7 +394,7 @@ function Operation({
id={feedbackTextareaId}
name="feedback-content"
value={feedbackContent}
onChange={e => setFeedbackContent(e.target.value)}
onValueChange={value => setFeedbackContent(value)}
placeholder={t('feedback.placeholder', { ns: 'common' }) || 'Please describe what went wrong or how we can improve…'}
rows={4}
className="w-full"

View File

@ -1,10 +1,10 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
@ -71,8 +71,9 @@ const InputsFormContent = ({ showTip }: Props) => {
)}
{form.type === InputVarType.paragraph && (
<Textarea
aria-label={form.label}
value={inputsFormValue?.[form.variable] || ''}
onChange={e => handleFormChange(form.variable, e.target.value)}
onValueChange={value => handleFormChange(form.variable, value)}
placeholder={form.label}
/>
)}

View File

@ -12,10 +12,10 @@ import { FieldItem, FieldRoot } from '@langgenius/dify-ui/field'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { produce } from 'immer'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
@ -220,9 +220,10 @@ const FollowUpSettingModal = ({
</div>
{promptMode === PROMPT_MODE.custom && (
<Textarea
aria-label={t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOption', { ns: 'appDebug' })}
className="mt-3 min-h-32 resize-y border-components-input-border-active bg-components-input-bg-normal"
value={prompt}
onChange={e => setPrompt(e.target.value)}
onValueChange={value => setPrompt(value)}
maxLength={CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH}
placeholder={t('feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder', { ns: 'appDebug' }) || ''}
/>

View File

@ -2,7 +2,7 @@ import type { FC } from 'react'
import type { CodeBasedExtensionForm } from '@/models/common'
import type { ModerationConfig } from '@/models/debug'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import Textarea from '@/app/components/base/textarea'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useLocale } from '@/context/i18n'
type FormGenerationProps = {
@ -55,10 +55,11 @@ const FormGeneration: FC<FormGenerationProps> = ({
form.type === 'paragraph' && (
<div className="relative">
<Textarea
aria-label={locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
className="resize-none"
value={value?.[form.variable] || ''}
placeholder={form.placeholder}
onChange={e => handleFormChange(form.variable, e.target.value)}
onValueChange={value => handleFormChange(form.variable, value)}
/>
</div>
)

View File

@ -1,6 +1,7 @@
import type { FC } from 'react'
import type { ModerationContentConfig } from '@/models/debug'
import { Switch } from '@langgenius/dify-ui/switch'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useTranslation } from 'react-i18next'
type ModerationContentProps = {
@ -50,12 +51,14 @@ const ModerationContent: FC<ModerationContentProps> = ({
{t('feature.moderation.modal.content.preset', { ns: 'appDebug' })}
<span className="text-xs font-normal text-text-tertiary">{t('feature.moderation.modal.content.supportMarkdown', { ns: 'appDebug' })}</span>
</div>
<div className="relative h-20 rounded-lg bg-components-input-bg-normal px-3 py-2">
<textarea
{/* Keep this counter composed locally; extract only if more textarea counter cases repeat. */}
<div className="relative h-20">
<Textarea
aria-label={t('feature.moderation.modal.content.preset', { ns: 'appDebug' }) as string}
value={config.preset_response || ''}
className="block size-full resize-none appearance-none bg-transparent text-sm text-text-secondary outline-hidden"
className="size-full resize-none pb-8"
placeholder={t('feature.moderation.modal.content.placeholder', { ns: 'appDebug' }) || ''}
onChange={e => handleConfigChange('preset_response', e.target.value)}
onValueChange={value => handleConfigChange('preset_response', value)}
/>
<div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 text-xs font-medium text-text-quaternary">
<span>{(config.preset_response || '').length}</span>

View File

@ -1,9 +1,10 @@
import type { ChangeEvent, FC } from 'react'
import type { FC } from 'react'
import type { CodeBasedExtensionItem } from '@/models/common'
import type { ModerationConfig, ModerationContentConfig } from '@/models/debug'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -103,9 +104,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
})
}
const handleDataKeywordsChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
const handleDataKeywordsChange = (value: string) => {
const arr = value.split('\n').reduce((prev: string[], next: string) => {
if (next !== '')
prev.push(next.slice(0, 100))
@ -292,11 +291,13 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
<div className="py-2">
<div className="mb-1 text-sm font-medium text-text-primary">{t('feature.moderation.modal.provider.keywords', { ns: 'appDebug' })}</div>
<div className="mb-2 text-xs text-text-tertiary">{t('feature.moderation.modal.keywords.tip', { ns: 'appDebug' })}</div>
<div className="relative h-[88px] rounded-lg bg-components-input-bg-normal px-3 py-2">
<textarea
{/* Keep this counter composed locally; extract only if more textarea counter cases repeat. */}
<div className="relative h-[88px]">
<Textarea
aria-label={t('feature.moderation.modal.provider.keywords', { ns: 'appDebug' }) as string}
value={localeData.config?.keywords || ''}
onChange={handleDataKeywordsChange}
className="block size-full resize-none appearance-none bg-transparent text-sm text-text-secondary outline-hidden"
onValueChange={handleDataKeywordsChange}
className="size-full resize-none pb-8"
placeholder={t('feature.moderation.modal.keywords.placeholder', { ns: 'appDebug' }) || ''}
/>
<div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 text-xs font-medium text-text-quaternary">

View File

@ -1,3 +1,4 @@
import type { ComponentProps } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import TextAreaField from '../text-area'
@ -30,4 +31,20 @@ describe('TextAreaField', () => {
fireEvent.change(screen.getByLabelText('Note'), { target: { value: 'Updated note' } })
expect(mockField.handleChange).toHaveBeenCalledWith('Updated note')
})
it('should keep form writeback when external props contain onValueChange', () => {
const externalOnValueChange = vi.fn()
render(
<TextAreaField
label="Note"
{...({ onValueChange: externalOnValueChange } as Partial<ComponentProps<typeof TextAreaField>>)}
/>,
)
fireEvent.change(screen.getByLabelText('Note'), { target: { value: 'Updated note' } })
expect(mockField.handleChange).toHaveBeenCalledWith('Updated note')
expect(externalOnValueChange).not.toHaveBeenCalled()
})
})

View File

@ -1,16 +1,16 @@
import type { TextareaProps } from '../../../textarea'
import type { TextareaProps } from '@langgenius/dify-ui/textarea'
import type { LabelProps } from '../label'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useFieldContext } from '../..'
import Textarea from '../../../textarea'
import Label from '../label'
type TextAreaFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
} & Omit<TextareaProps, 'className' | 'onChange' | 'onBlur' | 'value' | 'id'>
} & Omit<TextareaProps, 'className' | 'defaultValue' | 'onBlur' | 'onValueChange' | 'value' | 'id'>
const TextAreaField = ({
label,
@ -28,11 +28,11 @@ const TextAreaField = ({
{...(labelOptions ?? {})}
/>
<Textarea
{...inputProps}
id={field.name}
value={field.state.value}
onChange={e => field.handleChange(e.target.value)}
onValueChange={value => field.handleChange(value)}
onBlur={field.handleBlur}
{...inputProps}
/>
</div>
)

View File

@ -3,6 +3,7 @@ import type { Dayjs } from 'dayjs'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useChatContext } from '@/app/components/base/chat/chat/context'
@ -10,7 +11,6 @@ import DatePicker from '@/app/components/base/date-and-time-picker/date-picker'
import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
import { formatDateForOutput, toDayjs } from '@/app/components/base/date-and-time-picker/utils/dayjs'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
const DATA_FORMAT = {
TEXT: 'text',
@ -372,11 +372,12 @@ const MarkdownForm = ({ node }: { node: HastElement }) => {
return null
return (
<Textarea
aria-label={name}
key={key}
name={name}
placeholder={str(child.properties.placeholder)}
value={str(formValues[name])}
onChange={e => updateValue(name, e.target.value)}
onValueChange={value => updateValue(name, value)}
/>
)
}

View File

@ -2,12 +2,12 @@
import type { FC } from 'react'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Textarea } from '@langgenius/dify-ui/textarea'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import { VarType } from '@/app/components/workflow/types'
import Textarea from '../../../textarea'
import TagLabel from './tag-label'
import TypeSwitch from './type-switch'
@ -72,6 +72,7 @@ const PrePopulate: FC<Props> = ({
value,
onValueChange,
}) => {
const { t } = useTranslation()
const [onPlaceholderClicked, setOnPlaceholderClicked] = useState(false)
const handleTypeChange = useCallback((isVar: boolean) => {
setOnPlaceholderClicked(true)
@ -127,9 +128,10 @@ const PrePopulate: FC<Props> = ({
return (
<div className={cn('relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal pb-1', isFocus && 'border-components-input-border-active bg-components-input-bg-active shadow-xs')}>
<Textarea
aria-label={t(`${i18nPrefix}.staticContent`, { ns: 'workflow' })}
value={value || ''}
className="h-[43px] min-h-[43px] rounded-none border-none bg-transparent px-3 hover:bg-transparent focus:bg-transparent focus:shadow-none"
onChange={e => onValueChange?.(e.target.value)}
onValueChange={value => onValueChange?.(value)}
onFocus={() => {
setOnPlaceholderClicked(true)
setIsFocus(true)

View File

@ -1,77 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import TextArea from '../index'
describe('TextArea', () => {
it('should render correctly with default props', () => {
render(<TextArea value="" onChange={vi.fn()} />)
const textarea = screen.getByTestId('text-area')
expect(textarea).toBeInTheDocument()
expect(textarea).toHaveValue('')
})
it('should handle value and onChange correctly', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
const { rerender } = render(<TextArea value="initial" onChange={handleChange} />)
const textarea = screen.getByTestId('text-area')
expect(textarea).toHaveValue('initial')
await user.type(textarea, ' updated')
expect(handleChange).toHaveBeenCalled()
rerender(<TextArea value="initial updated" onChange={handleChange} />)
expect(textarea).toHaveValue('initial updated')
})
it('should handle autoFocus correctly', () => {
render(<TextArea value="" onChange={vi.fn()} autoFocus />)
const textarea = screen.getByTestId('text-area')
expect(textarea).toHaveFocus()
})
it('should handle disabled state', () => {
render(<TextArea value="" onChange={vi.fn()} disabled />)
const textarea = screen.getByTestId('text-area')
expect(textarea).toBeDisabled()
expect(textarea).toHaveClass('cursor-not-allowed')
})
it('should handle placeholder', () => {
render(<TextArea value="" onChange={vi.fn()} placeholder="Enter text here" />)
expect(screen.getByPlaceholderText('Enter text here')).toBeInTheDocument()
})
it('should handle className', () => {
render(<TextArea value="" onChange={vi.fn()} className="custom-class" />)
expect(screen.getByTestId('text-area')).toHaveClass('custom-class')
})
it('should handle size variants', () => {
const { rerender } = render(<TextArea value="" onChange={vi.fn()} size="small" />)
expect(screen.getByTestId('text-area')).toHaveClass('py-1')
rerender(<TextArea value="" onChange={vi.fn()} size="large" />)
expect(screen.getByTestId('text-area')).toHaveClass('px-4')
})
it('should handle destructive state', () => {
render(<TextArea value="" onChange={vi.fn()} destructive />)
expect(screen.getByTestId('text-area')).toHaveClass('border-components-input-border-destructive')
})
it('should handle onFocus and onBlur', async () => {
const user = userEvent.setup()
const handleFocus = vi.fn()
const handleBlur = vi.fn()
render(<TextArea value="" onChange={vi.fn()} onFocus={handleFocus} onBlur={handleBlur} />)
const textarea = screen.getByTestId('text-area')
await user.click(textarea)
expect(handleFocus).toHaveBeenCalled()
await user.tab()
expect(handleBlur).toHaveBeenCalled()
})
})

View File

@ -1,562 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import Textarea from '.'
const meta = {
title: 'Base/Data Entry/Textarea',
component: Textarea,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Textarea component with multiple sizes (small, regular, large). Built with class-variance-authority for consistent styling.',
},
},
},
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['small', 'regular', 'large'],
description: 'Textarea size',
},
value: {
control: 'text',
description: 'Textarea value',
},
placeholder: {
control: 'text',
description: 'Placeholder text',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
destructive: {
control: 'boolean',
description: 'Error/destructive state',
},
rows: {
control: 'number',
description: 'Number of visible text rows',
},
},
} satisfies Meta<typeof Textarea>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const TextareaDemo = (args: any) => {
const [value, setValue] = useState(args.value || '')
return (
<div style={{ width: '500px' }}>
<Textarea
{...args}
value={value}
onChange={(e) => {
setValue(e.target.value)
console.log('Textarea changed:', e.target.value)
}}
/>
{value && (
<div className="mt-3 text-sm text-gray-600">
Character count:
{' '}
<span className="font-semibold">{value.length}</span>
</div>
)}
</div>
)
}
// Default state
export const Default: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
placeholder: 'Enter text...',
rows: 4,
value: '',
},
}
// Small size
export const SmallSize: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'small',
placeholder: 'Small textarea...',
rows: 3,
value: '',
},
}
// Large size
export const LargeSize: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'large',
placeholder: 'Large textarea...',
rows: 5,
value: '',
},
}
// With initial value
export const WithInitialValue: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
value: 'This is some initial text content.\n\nIt spans multiple lines.',
rows: 4,
},
}
// Disabled state
export const Disabled: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
value: 'This textarea is disabled and cannot be edited.',
disabled: true,
rows: 3,
},
}
// Destructive/error state
export const DestructiveState: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
value: 'This content has an error.',
destructive: true,
rows: 3,
},
}
// Size comparison
const SizeComparisonDemo = () => {
const [small, setSmall] = useState('')
const [regular, setRegular] = useState('')
const [large, setLarge] = useState('')
return (
<div style={{ width: '600px' }} className="space-y-4">
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Small</label>
<Textarea
size="small"
value={small}
onChange={e => setSmall(e.target.value)}
placeholder="Small textarea..."
rows={3}
/>
</div>
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Regular</label>
<Textarea
size="regular"
value={regular}
onChange={e => setRegular(e.target.value)}
placeholder="Regular textarea..."
rows={4}
/>
</div>
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Large</label>
<Textarea
size="large"
value={large}
onChange={e => setLarge(e.target.value)}
placeholder="Large textarea..."
rows={5}
/>
</div>
</div>
)
}
export const SizeComparison: Story = {
render: () => <SizeComparisonDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// State comparison
const StateComparisonDemo = () => {
const [normal, setNormal] = useState('Normal state')
const [error, setError] = useState('Error state')
return (
<div style={{ width: '500px' }} className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Normal</label>
<Textarea
value={normal}
onChange={e => setNormal(e.target.value)}
rows={3}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Destructive</label>
<Textarea
value={error}
onChange={e => setError(e.target.value)}
destructive
rows={3}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Disabled</label>
<Textarea
value="Disabled state"
onChange={() => undefined}
disabled
rows={3}
/>
</div>
</div>
)
}
export const StateComparison: Story = {
render: () => <StateComparisonDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Comment form
const CommentFormDemo = () => {
const [comment, setComment] = useState('')
const maxLength = 500
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Leave a Comment</h3>
<Textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="Share your thoughts..."
rows={5}
maxLength={maxLength}
/>
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-gray-500">
{comment.length}
{' '}
/
{maxLength}
{' '}
characters
</span>
<button
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={comment.trim().length === 0}
>
Post Comment
</button>
</div>
</div>
)
}
export const CommentForm: Story = {
render: () => <CommentFormDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Feedback form
const FeedbackFormDemo = () => {
const [feedback, setFeedback] = useState('')
const [email, setEmail] = useState('')
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-2 text-lg font-semibold">Send Feedback</h3>
<p className="mb-4 text-sm text-gray-600">Help us improve our product</p>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Your Email</label>
<input
type="email"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="email@example.com"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Your Feedback</label>
<Textarea
value={feedback}
onChange={e => setFeedback(e.target.value)}
placeholder="Tell us what you think..."
rows={6}
/>
</div>
<button className="w-full rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">
Submit Feedback
</button>
</div>
</div>
)
}
export const FeedbackForm: Story = {
render: () => <FeedbackFormDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Code snippet
const CodeSnippetDemo = () => {
const [code, setCode] = useState(`function hello() {
console.log("Hello, world!");
}`)
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Code Editor</h3>
<Textarea
value={code}
onChange={e => setCode(e.target.value)}
className="font-mono"
rows={8}
/>
<div className="mt-4 flex gap-2">
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Run Code
</button>
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
Copy
</button>
</div>
</div>
)
}
export const CodeSnippet: Story = {
render: () => <CodeSnippetDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Message composer
const MessageComposerDemo = () => {
const [message, setMessage] = useState('')
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Compose Message</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">To</label>
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="Recipient name"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Subject</label>
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="Message subject"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Message</label>
<Textarea
value={message}
onChange={e => setMessage(e.target.value)}
placeholder="Type your message here..."
rows={8}
/>
</div>
<div className="flex gap-2">
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Send Message
</button>
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
Save Draft
</button>
</div>
</div>
</div>
)
}
export const MessageComposer: Story = {
render: () => <MessageComposerDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Bio editor
const BioEditorDemo = () => {
const [bio, setBio] = useState('Software developer passionate about building great products.')
const maxLength = 200
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Edit Your Bio</h3>
<Textarea
value={bio}
onChange={e => setBio(e.target.value.slice(0, maxLength))}
placeholder="Tell us about yourself..."
rows={4}
/>
<div className="mt-2 flex items-center justify-between text-xs">
<span className={bio.length > maxLength * 0.9 ? 'text-orange-600' : 'text-gray-500'}>
{bio.length}
{' '}
/
{maxLength}
{' '}
characters
</span>
{bio.length > maxLength * 0.9 && (
<span className="text-orange-600">
{maxLength - bio.length}
{' '}
characters remaining
</span>
)}
</div>
<div className="mt-4 rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-xs font-medium text-gray-600">Preview:</div>
<p className="text-sm text-gray-800">{bio || 'Your bio will appear here...'}</p>
</div>
</div>
)
}
export const BioEditor: Story = {
render: () => <BioEditorDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - JSON editor
const JSONEditorDemo = () => {
const [json, setJson] = useState(`{
"name": "John Doe",
"age": 30,
"email": "john@example.com"
}`)
const [isValid, setIsValid] = useState(true)
const validateJSON = (value: string) => {
try {
JSON.parse(value)
setIsValid(true)
}
catch {
setIsValid(false)
}
}
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">JSON Editor</h3>
<span className={`rounded-sm px-2 py-1 text-xs ${isValid ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{isValid ? '✓ Valid' : '✗ Invalid'}
</span>
</div>
<Textarea
value={json}
onChange={(e) => {
setJson(e.target.value)
validateJSON(e.target.value)
}}
className="font-mono"
destructive={!isValid}
rows={10}
/>
<div className="mt-4 flex gap-2">
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50" disabled={!isValid}>
Save JSON
</button>
<button
className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300"
onClick={() => {
try {
const formatted = JSON.stringify(JSON.parse(json), null, 2)
setJson(formatted)
}
catch {
// Invalid JSON, do nothing
}
}}
>
Format
</button>
</div>
</div>
)
}
export const JSONEditor: Story = {
render: () => <JSONEditorDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Task description
const TaskDescriptionDemo = () => {
const [title, setTitle] = useState('Implement user authentication')
const [description, setDescription] = useState('Add login and registration functionality with JWT tokens.')
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Create New Task</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Task Title</label>
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Description</label>
<Textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Describe the task in detail..."
rows={6}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Priority</label>
<select className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm">
<option>Low</option>
<option>Medium</option>
<option>High</option>
<option>Urgent</option>
</select>
</div>
<button className="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Create Task
</button>
</div>
</div>
)
}
export const TaskDescription: Story = {
render: () => <TaskDescriptionDemo />,
parameters: { controls: { disable: true } },
} as unknown as Story
// Interactive playground
export const Playground: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
placeholder: 'Enter text...',
rows: 4,
disabled: false,
destructive: false,
value: '',
},
}

View File

@ -1,60 +0,0 @@
import type { VariantProps } from 'class-variance-authority'
import type { CSSProperties } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { cva } from 'class-variance-authority'
import * as React from 'react'
const textareaVariants = cva(
'',
{
variants: {
size: {
small: 'rounded-md py-1 system-xs-regular',
regular: 'rounded-md px-3 system-sm-regular',
large: 'rounded-lg px-4 system-md-regular',
},
},
defaultVariants: {
size: 'regular',
},
},
)
export type TextareaProps = {
value: string | number
disabled?: boolean
destructive?: boolean
styleCss?: CSSProperties
ref?: React.Ref<HTMLTextAreaElement>
onFocus?: React.FocusEventHandler<HTMLTextAreaElement>
onBlur?: React.FocusEventHandler<HTMLTextAreaElement>
} & React.TextareaHTMLAttributes<HTMLTextAreaElement> & VariantProps<typeof textareaVariants>
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, value, onChange, disabled, size, destructive, styleCss, onFocus, onBlur, ...props }, ref) => {
return (
<textarea
ref={ref}
onFocus={onFocus}
onBlur={onBlur}
style={styleCss}
className={cn(
'min-h-20 w-full appearance-none border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
textareaVariants({ size }),
disabled && 'cursor-not-allowed border-transparent bg-components-input-bg-disabled text-components-input-text-filled-disabled hover:border-transparent hover:bg-components-input-bg-disabled',
destructive && 'border-components-input-border-destructive bg-components-input-bg-destructive text-components-input-text-filled hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive focus:border-components-input-border-destructive focus:bg-components-input-bg-destructive',
className,
)}
value={value ?? ''}
onChange={onChange}
disabled={disabled}
data-testid="text-area"
{...props}
>
</textarea>
)
},
)
Textarea.displayName = 'Textarea'
export default Textarea

View File

@ -25,6 +25,7 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({
const total = plan.total.buildApps
const percent = total > 0 ? (usage / total) * 100 : 0
const tone: MeterTone = percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'neutral'
const buildAppsLabel = t('usagePage.buildApps', { ns: 'billing' })
return (
<div className={cn(
'flex flex-col gap-3 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-4 shadow-xs backdrop-blur-xs',
@ -61,14 +62,14 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between system-xs-medium text-text-secondary">
<div>{t('usagePage.buildApps', { ns: 'billing' })}</div>
<div>{buildAppsLabel}</div>
<div>
{usage}
/
{total}
</div>
</div>
<MeterRoot value={Math.min(percent, 100)} max={100}>
<MeterRoot value={Math.min(percent, 100)} max={100} aria-label={buildAppsLabel}>
<MeterTrack>
<MeterIndicator tone={tone} />
</MeterTrack>

View File

@ -229,7 +229,7 @@ describe('UsageInfo', () => {
/>,
)
expect(screen.getByRole('meter')).toBeInTheDocument()
expect(screen.getByRole('meter', { name: 'Storage' })).toBeInTheDocument()
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
})
@ -270,7 +270,7 @@ describe('UsageInfo', () => {
/>,
)
expect(screen.getByRole('meter')).toBeInTheDocument()
expect(screen.getByRole('meter', { name: 'Storage' })).toBeInTheDocument()
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
})

View File

@ -144,13 +144,13 @@ const UsageInfo: FC<Props> = ({
<div
className={cn(
'h-1 rounded-md bg-progress-bar-indeterminate-stripe',
isSandboxPlan ? 'w-full' : 'w-[30px]',
isSandboxPlan ? 'w-full' : 'w-7.5',
)}
/>
</div>
)
: (
<MeterRoot value={effectivePercent} max={100}>
<MeterRoot value={effectivePercent} max={100} aria-label={name}>
<MeterTrack>
<MeterIndicator tone={tone} />
</MeterTrack>
@ -162,7 +162,7 @@ const UsageInfo: FC<Props> = ({
return (
<Tooltip>
<TooltipTrigger render={<div className="cursor-default">{children}</div>} />
<TooltipContent className="w-[200px] max-w-[200px]">
<TooltipContent className="w-50 max-w-50">
{storageTooltip}
</TooltipContent>
</Tooltip>

View File

@ -1,6 +1,7 @@
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { PipelineTemplate } from '@/models/pipeline'
import { Button } from '@langgenius/dify-ui/button'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
@ -9,7 +10,6 @@ import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { useInvalidCustomizedTemplateList, useUpdateTemplateInfo } from '@/service/use-pipeline'
type EditPipelineInfoProps = {
@ -45,8 +45,7 @@ const EditPipelineInfo = ({
setAppIcon(icon)
}, [])
const handleDescriptionChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value
const handleDescriptionChange = useCallback((value: string) => {
setDescription(value)
}, [])
@ -121,7 +120,8 @@ const EditPipelineInfo = ({
{t('knowledgeDescription', { ns: 'datasetPipeline' })}
</label>
<Textarea
onChange={handleDescriptionChange}
aria-label={t('knowledgeDescription', { ns: 'datasetPipeline' })}
onValueChange={handleDescriptionChange}
value={description}
placeholder={t('knowledgeDescriptionPlaceholder', { ns: 'datasetPipeline' })}
/>

View File

@ -244,6 +244,15 @@ describe('DatasetCard Component', () => {
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents')
})
it('should not change background color on hover', () => {
const dataset = createMockDataset()
render(<DatasetCard dataset={dataset} />)
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
expect(card).toHaveClass('bg-components-card-bg')
expect(card).not.toHaveClass('hover:bg-components-card-bg-alt')
})
it('should navigate to hitTesting for external provider', () => {
const dataset = createMockDataset({ provider: 'external' })
render(<DatasetCard dataset={dataset} />)

View File

@ -63,7 +63,7 @@ const DatasetCard = ({
return (
<>
<div
className="group relative col-span-1 flex h-[190px] cursor-pointer flex-col rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-all duration-200 ease-in-out hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5"
className="group relative col-span-1 flex h-47.5 cursor-pointer flex-col rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-all duration-200 ease-in-out hover:shadow-md hover:shadow-shadow-shadow-5"
data-disable-nprogress={true}
onClick={handleCardClick}
>

View File

@ -5,12 +5,12 @@ import type { DataSet } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { updateDatasetSetting } from '@/service/datasets'
import AppIcon from '../../base/app-icon'
import AppIconPicker from '../../base/app-icon-picker'
@ -108,7 +108,7 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset
{t('form.desc', { ns: 'datasetSettings' })}
</div>
<div className="w-full">
<Textarea value={description} onChange={e => setDescription(e.target.value)} className="resize-none" placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''} />
<Textarea aria-label={t('form.desc', { ns: 'datasetSettings' })} value={description} onValueChange={value => setDescription(value)} className="resize-none" placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''} />
</div>
</div>
</div>

View File

@ -3,11 +3,11 @@ import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { Member } from '@/models/common'
import type { DataSet, DatasetPermission, IconInfo } from '@/models/datasets'
import type { AppIconType } from '@/types/app'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import PermissionSelector from '../../permission-selector'
const rowClass = 'flex gap-x-1'
@ -85,11 +85,12 @@ const BasicInfoSection = ({
</div>
<div className="grow">
<Textarea
aria-label={t('form.desc', { ns: 'datasetSettings' })}
disabled={!currentDataset?.embedding_available}
className="resize-none"
placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''}
value={description}
onChange={e => setDescription(e.target.value)}
onValueChange={value => setDescription(value)}
/>
</div>
</div>

View File

@ -1,7 +1,7 @@
import type { ChangeEvent } from 'react'
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import { Switch } from '@langgenius/dify-ui/switch'
import { Textarea } from '@langgenius/dify-ui/textarea'
import {
memo,
useCallback,
@ -9,7 +9,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import Textarea from '@/app/components/base/textarea'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
@ -53,9 +52,9 @@ const SummaryIndexSetting = ({
})
}, [onSummaryIndexSettingChange])
const handleSummaryIndexPromptChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
const handleSummaryIndexPromptChange = useCallback((value: string) => {
onSummaryIndexSettingChange?.({
summary_prompt: e.target.value,
summary_prompt: value,
})
}, [onSummaryIndexSettingChange])
@ -95,8 +94,9 @@ const SummaryIndexSetting = ({
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
</div>
<Textarea
aria-label={t('form.summaryInstructions', { ns: 'datasetSettings' })}
value={summaryIndexSetting?.summary_prompt ?? ''}
onChange={handleSummaryIndexPromptChange}
onValueChange={handleSummaryIndexPromptChange}
disabled={readonly}
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
/>
@ -166,8 +166,9 @@ const SummaryIndexSetting = ({
</div>
<div className="grow">
<Textarea
aria-label={t('form.summaryInstructions', { ns: 'datasetSettings' })}
value={summaryIndexSetting?.summary_prompt ?? ''}
onChange={handleSummaryIndexPromptChange}
onValueChange={handleSummaryIndexPromptChange}
disabled={readonly}
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
/>
@ -214,8 +215,9 @@ const SummaryIndexSetting = ({
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
</div>
<Textarea
aria-label={t('form.summaryInstructions', { ns: 'datasetSettings' })}
value={summaryIndexSetting?.summary_prompt ?? ''}
onChange={handleSummaryIndexPromptChange}
onValueChange={handleSummaryIndexPromptChange}
disabled={readonly}
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
/>

View File

@ -3,6 +3,7 @@ import type { AppIconType } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Switch } from '@langgenius/dify-ui/switch'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useDebounceFn, useKeyPress } from 'ahooks'
import * as React from 'react'
@ -10,7 +11,6 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { useProviderContext } from '@/context/provider-context'
import { AppModeEnum } from '@/types/app'
@ -145,10 +145,11 @@ const CreateAppModal = ({
<div className="pt-2">
<div className="py-2 text-sm leading-[20px] font-medium text-text-primary">{t('newApp.captionDescription', { ns: 'app' })}</div>
<Textarea
aria-label={t('newApp.captionDescription', { ns: 'app' })}
className="resize-none"
placeholder={t('newApp.appDescriptionPlaceholder', { ns: 'app' }) || ''}
value={description}
onChange={e => setDescription(e.target.value)}
onValueChange={value => setDescription(value)}
/>
</div>
{/* answer icon */}

View File

@ -1,9 +1,9 @@
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { InputVarType } from '@/app/components/workflow/types'
type Props = {
@ -55,8 +55,9 @@ const AppInputsForm = ({
if (form.type === InputVarType.paragraph) {
return (
<Textarea
aria-label={label}
value={inputs[variable] || ''}
onChange={e => handleFormChange(variable, e.target.value)}
onValueChange={value => handleFormChange(variable, value)}
placeholder={label}
/>
)

View File

@ -523,9 +523,7 @@ describe('useToolSelectorState Hook', () => {
)
act(() => {
result.current.handleDescriptionChange({
target: { value: 'new description' },
} as React.ChangeEvent<HTMLTextAreaElement>)
result.current.handleDescriptionChange('new description')
})
expect(onSelect).toHaveBeenCalledWith(
@ -1724,9 +1722,7 @@ describe('Edge Cases', () => {
)
act(() => {
result.current.handleDescriptionChange({
target: { value: '' },
} as React.ChangeEvent<HTMLTextAreaElement>)
result.current.handleDescriptionChange('')
})
expect(onSelect).toHaveBeenCalledWith(

View File

@ -1,24 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/app/components/base/textarea', () => ({
default: ({ value, onChange, disabled, placeholder }: {
value?: string
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
disabled?: boolean
placeholder?: string
}) => (
<textarea
data-testid="description-textarea"
value={value || ''}
onChange={onChange}
disabled={disabled}
placeholder={placeholder}
/>
),
}))
vi.mock('../../../../readme-panel/entrance', () => ({
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
}))
@ -68,28 +50,28 @@ describe('ToolBaseForm', () => {
it('should render description textarea', () => {
render(<ToolBaseForm {...defaultProps} />)
expect(screen.getByTestId('description-textarea')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should disable textarea when no provider_name in value', () => {
render(<ToolBaseForm {...defaultProps} />)
expect(screen.getByTestId('description-textarea')).toBeDisabled()
expect(screen.getByRole('textbox')).toBeDisabled()
})
it('should enable textarea when value has provider_name', () => {
const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never
render(<ToolBaseForm {...defaultProps} value={value} />)
expect(screen.getByTestId('description-textarea')).not.toBeDisabled()
expect(screen.getByRole('textbox')).not.toBeDisabled()
})
it('should call onDescriptionChange when textarea content changes', () => {
const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never
render(<ToolBaseForm {...defaultProps} value={value} />)
fireEvent.change(screen.getByTestId('description-textarea'), { target: { value: 'Updated' } })
expect(mockOnDescriptionChange).toHaveBeenCalled()
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Updated' } })
expect(mockOnDescriptionChange).toHaveBeenCalledWith('Updated', expect.any(Object))
})
it('should show ReadmeEntrance when provider has plugin_unique_identifier', () => {

View File

@ -4,8 +4,8 @@ import type { FC } from 'react'
import type { PluginDetail } from '@/app/components/plugins/types'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useTranslation } from 'react-i18next'
import Textarea from '@/app/components/base/textarea'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import { ReadmeEntrance } from '../../../readme-panel/entrance'
import ToolTrigger from './tool-trigger'
@ -23,7 +23,7 @@ type ToolBaseFormProps = {
onPanelShowStateChange?: (state: boolean) => void
onSelectTool: (tool: ToolDefaultValue) => void
onSelectMultipleTool: (tools: ToolDefaultValue[]) => void
onDescriptionChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onDescriptionChange: (value: string) => void
}
const ToolBaseForm: FC<ToolBaseFormProps> = ({
@ -85,9 +85,10 @@ const ToolBaseForm: FC<ToolBaseFormProps> = ({
</div>
<Textarea
className="resize-none"
aria-label={t('detailPanel.toolSelector.descriptionLabel', { ns: 'plugin' })}
placeholder={t('detailPanel.toolSelector.descriptionPlaceholder', { ns: 'plugin' })}
value={value?.extra?.description || ''}
onChange={onDescriptionChange}
onValueChange={onDescriptionChange}
disabled={!value?.provider_name}
/>
</div>

Some files were not shown because too many files have changed in this diff Show More