mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:32:01 +08:00
feat: add agent backend plugin layer (#36686)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
58b8fc21d4
commit
ebff9a3639
@ -31,6 +31,7 @@ from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAge
|
||||
from clients.agent_backend.request_builder import (
|
||||
AGENT_SOUL_PROMPT_LAYER_ID,
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
DIFY_PLUGIN_TOOLS_LAYER_ID,
|
||||
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
|
||||
WORKFLOW_USER_PROMPT_LAYER_ID,
|
||||
AgentBackendModelConfig,
|
||||
@ -43,6 +44,7 @@ from clients.agent_backend.request_builder import (
|
||||
__all__ = [
|
||||
"AGENT_SOUL_PROMPT_LAYER_ID",
|
||||
"DIFY_EXECUTION_CONTEXT_LAYER_ID",
|
||||
"DIFY_PLUGIN_TOOLS_LAYER_ID",
|
||||
"WORKFLOW_NODE_JOB_PROMPT_LAYER_ID",
|
||||
"WORKFLOW_USER_PROMPT_LAYER_ID",
|
||||
"AgentBackendError",
|
||||
|
||||
@ -18,8 +18,10 @@ 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_LLM_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
DifyPluginCredentialValue,
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.execution_context import (
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
@ -41,6 +43,7 @@ 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_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
|
||||
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
|
||||
|
||||
|
||||
class AgentBackendModelConfig(BaseModel):
|
||||
@ -81,6 +84,7 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
purpose: RunPurpose = "workflow_node"
|
||||
idempotency_key: str | None = None
|
||||
output: AgentBackendOutputConfig | None = None
|
||||
tools: DifyPluginToolsLayerConfig | None = None
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
suspend_on_exit: bool = False
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
@ -147,6 +151,17 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
if run_input.tools is not None and run_input.tools.tools:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_PLUGIN_TOOLS_LAYER_ID,
|
||||
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.tools,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.output is not None:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
|
||||
268
api/core/workflow/nodes/agent_v2/plugin_tools_builder.py
Normal file
268
api/core/workflow/nodes/agent_v2/plugin_tools_builder.py
Normal file
@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Protocol, cast
|
||||
|
||||
from dify_agent.layers.dify_plugin import (
|
||||
DifyPluginCredentialValue,
|
||||
DifyPluginToolConfig,
|
||||
DifyPluginToolCredentialType,
|
||||
DifyPluginToolParameter,
|
||||
DifyPluginToolParameterForm,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
|
||||
from core.agent.entities import AgentToolEntity
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.tools.__base.tool import Tool
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from core.tools.errors import (
|
||||
ToolProviderCredentialValidationError,
|
||||
ToolProviderNotFoundError,
|
||||
)
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from models.agent_config_entities import AgentSoulDifyToolConfig, AgentSoulToolsConfig
|
||||
from models.provider_ids import ToolProviderID
|
||||
|
||||
|
||||
class WorkflowAgentPluginToolsBuildError(ValueError):
|
||||
"""Raised when Agent Soul tools cannot be prepared for Agent backend."""
|
||||
|
||||
def __init__(self, error_code: str, message: str) -> None:
|
||||
self.error_code = error_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class AgentToolRuntimeProvider(Protocol):
|
||||
def get_agent_tool_runtime(
|
||||
self,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
agent_tool: AgentToolEntity,
|
||||
user_id: str | None = None,
|
||||
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
|
||||
variable_pool: Any | None = None,
|
||||
) -> Tool: ...
|
||||
|
||||
|
||||
class WorkflowAgentPluginToolsBuilder:
|
||||
"""Prepare Agent Soul Dify Plugin Tools for the public Agent backend DTO."""
|
||||
|
||||
def __init__(self, *, tool_runtime_provider: AgentToolRuntimeProvider | None = None) -> None:
|
||||
self._tool_runtime_provider = tool_runtime_provider or ToolManager
|
||||
|
||||
def build(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
user_id: str | None,
|
||||
tools: AgentSoulToolsConfig,
|
||||
invoke_from: InvokeFrom,
|
||||
) -> DifyPluginToolsLayerConfig | None:
|
||||
"""Resolve user-selected Dify Plugin Tools into the Agent backend DTO.
|
||||
|
||||
``invoke_from`` is the *real* runtime caller category (DEBUGGER for a
|
||||
Composer test run, SERVICE_API / WEB_APP for a published run). It must
|
||||
be threaded through to :class:`ToolManager` so credential quotas, rate
|
||||
limits, and audit tags match the actual call site.
|
||||
"""
|
||||
enabled_tools = [tool for tool in tools.dify_tools if tool.enabled]
|
||||
if not enabled_tools:
|
||||
return None
|
||||
|
||||
prepared: list[DifyPluginToolConfig] = []
|
||||
seen_names: set[str] = set()
|
||||
for tool_config in enabled_tools:
|
||||
agent_tool = self._to_agent_tool_entity(tool_config)
|
||||
tool_runtime = self._fetch_tool_runtime(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
user_id=user_id,
|
||||
agent_tool=agent_tool,
|
||||
invoke_from=invoke_from,
|
||||
tool_config=tool_config,
|
||||
)
|
||||
|
||||
exposed_name = self._exposed_tool_name(tool_config)
|
||||
if exposed_name in seen_names:
|
||||
raise WorkflowAgentPluginToolsBuildError(
|
||||
"agent_tool_name_duplicated",
|
||||
f"Duplicate Dify Plugin Tool name {exposed_name!r}.",
|
||||
)
|
||||
seen_names.add(exposed_name)
|
||||
|
||||
prepared.append(self._to_backend_tool_config(tool_config, tool_runtime, exposed_name))
|
||||
|
||||
return DifyPluginToolsLayerConfig(tools=prepared)
|
||||
|
||||
def _fetch_tool_runtime(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
user_id: str | None,
|
||||
agent_tool: AgentToolEntity,
|
||||
invoke_from: InvokeFrom,
|
||||
tool_config: AgentSoulDifyToolConfig,
|
||||
) -> Tool:
|
||||
"""Resolve the API-side ``Tool`` runtime, mapping fetch errors to
|
||||
Inspector-friendly error codes so callers can render distinct UX for
|
||||
"tool definition gone" vs "credential failed".
|
||||
"""
|
||||
try:
|
||||
return self._tool_runtime_provider.get_agent_tool_runtime(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
agent_tool=agent_tool,
|
||||
user_id=user_id,
|
||||
invoke_from=invoke_from,
|
||||
variable_pool=None,
|
||||
)
|
||||
except ToolProviderNotFoundError as exc:
|
||||
raise WorkflowAgentPluginToolsBuildError(
|
||||
"agent_tool_declaration_not_found",
|
||||
f"Dify Plugin Tool {tool_config.tool_name!r} declaration not found: {exc}",
|
||||
) from exc
|
||||
except ToolProviderCredentialValidationError as exc:
|
||||
raise WorkflowAgentPluginToolsBuildError(
|
||||
"agent_tool_credential_invalid",
|
||||
f"Dify Plugin Tool {tool_config.tool_name!r} credential validation failed: {exc}",
|
||||
) from exc
|
||||
except ValueError as exc:
|
||||
# ToolManager raises bare ValueError when the agent tool's
|
||||
# ``runtime`` / runtime parameters are missing. Surface it under a
|
||||
# narrower error code than a generic "declaration not found" so
|
||||
# frontend can render an actionable hint.
|
||||
raise WorkflowAgentPluginToolsBuildError(
|
||||
"agent_tool_config_invalid",
|
||||
f"Dify Plugin Tool {tool_config.tool_name!r} runtime construction failed: {exc}",
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _to_agent_tool_entity(tool_config: AgentSoulDifyToolConfig) -> AgentToolEntity:
|
||||
return AgentToolEntity(
|
||||
provider_type=ToolProviderType.value_of(tool_config.provider_type),
|
||||
provider_id=WorkflowAgentPluginToolsBuilder._provider_id(tool_config),
|
||||
tool_name=tool_config.tool_name,
|
||||
tool_parameters=dict(tool_config.runtime_parameters),
|
||||
credential_id=tool_config.credential_ref.id if tool_config.credential_ref else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _provider_id(tool_config: AgentSoulDifyToolConfig) -> str:
|
||||
if tool_config.provider_id:
|
||||
return tool_config.provider_id
|
||||
assert tool_config.plugin_id is not None
|
||||
assert tool_config.provider is not None
|
||||
return f"{tool_config.plugin_id}/{tool_config.provider}"
|
||||
|
||||
@staticmethod
|
||||
def _exposed_tool_name(tool_config: AgentSoulDifyToolConfig) -> str:
|
||||
# Stage 3.1 decision: no user rename yet. Keep the model-visible tool
|
||||
# name aligned with the plugin declaration identity.
|
||||
return tool_config.tool_name
|
||||
|
||||
def _to_backend_tool_config(
|
||||
self,
|
||||
tool_config: AgentSoulDifyToolConfig,
|
||||
tool_runtime: Tool,
|
||||
exposed_name: str,
|
||||
) -> DifyPluginToolConfig:
|
||||
runtime = tool_runtime.runtime
|
||||
if runtime is None:
|
||||
raise WorkflowAgentPluginToolsBuildError(
|
||||
"agent_tool_config_invalid",
|
||||
f"Dify Plugin Tool {tool_config.tool_name!r} has no runtime.",
|
||||
)
|
||||
|
||||
provider_id = self._provider_id(tool_config)
|
||||
plugin_id, provider = self._plugin_provider(tool_config, provider_id)
|
||||
parameters = [
|
||||
DifyPluginToolParameter.model_validate(parameter.model_dump(mode="json"))
|
||||
for parameter in tool_runtime.get_merged_runtime_parameters()
|
||||
]
|
||||
runtime_parameters = self._runtime_parameters(tool_runtime, parameters)
|
||||
description = tool_config.description
|
||||
if description is None and tool_runtime.entity.description is not None:
|
||||
description = tool_runtime.entity.description.llm
|
||||
|
||||
return DifyPluginToolConfig(
|
||||
plugin_id=plugin_id,
|
||||
provider=provider,
|
||||
tool_name=tool_config.tool_name,
|
||||
credential_type=self._credential_type(tool_config, runtime.credentials),
|
||||
name=exposed_name,
|
||||
description=description,
|
||||
credentials=self._normalize_credentials(runtime.credentials, tool_name=tool_config.tool_name),
|
||||
runtime_parameters=runtime_parameters,
|
||||
parameters=parameters,
|
||||
parameters_json_schema=cast(dict[str, Any], tool_runtime.get_llm_parameters_json_schema()),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _plugin_provider(tool_config: AgentSoulDifyToolConfig, provider_id: str) -> tuple[str, str]:
|
||||
if tool_config.plugin_id and tool_config.provider:
|
||||
return tool_config.plugin_id, tool_config.provider
|
||||
provider_id_entity = ToolProviderID(provider_id)
|
||||
return provider_id_entity.plugin_id, provider_id_entity.provider_name
|
||||
|
||||
@staticmethod
|
||||
def _credential_type(
|
||||
tool_config: AgentSoulDifyToolConfig,
|
||||
credentials: Mapping[str, Any],
|
||||
) -> DifyPluginToolCredentialType:
|
||||
if not credentials and tool_config.credential_type == "unauthorized":
|
||||
return "unauthorized"
|
||||
return tool_config.credential_type
|
||||
|
||||
@staticmethod
|
||||
def _runtime_parameters(
|
||||
tool_runtime: Tool,
|
||||
parameters: list[DifyPluginToolParameter],
|
||||
) -> dict[str, Any]:
|
||||
runtime = tool_runtime.runtime
|
||||
runtime_parameters = dict(runtime.runtime_parameters if runtime is not None else {})
|
||||
missing = [
|
||||
parameter.name
|
||||
for parameter in parameters
|
||||
if parameter.form is not DifyPluginToolParameterForm.LLM
|
||||
and parameter.required
|
||||
and parameter.default is None
|
||||
and parameter.name not in runtime_parameters
|
||||
]
|
||||
if missing:
|
||||
names = ", ".join(sorted(missing))
|
||||
raise WorkflowAgentPluginToolsBuildError(
|
||||
"agent_tool_runtime_parameter_missing",
|
||||
f"Dify Plugin Tool {tool_runtime.entity.identity.name!r} is missing runtime parameters: {names}.",
|
||||
)
|
||||
return runtime_parameters
|
||||
|
||||
@staticmethod
|
||||
def _normalize_credentials(
|
||||
credentials: Mapping[str, Any],
|
||||
*,
|
||||
tool_name: str,
|
||||
) -> dict[str, DifyPluginCredentialValue]:
|
||||
"""Forward only scalar credential values to the Agent backend.
|
||||
|
||||
``DifyPluginCredentialValue`` is ``str | int | float | bool | None``.
|
||||
Refusing non-scalar values (lists, dicts, custom objects) is safer than
|
||||
``str(value)`` — stringifying a nested OAuth token blob produces a
|
||||
Python ``repr`` that the plugin daemon cannot use, and we'd rather
|
||||
surface a clear ``agent_tool_credential_shape_invalid`` than send junk.
|
||||
"""
|
||||
normalized: dict[str, DifyPluginCredentialValue] = {}
|
||||
for key, value in credentials.items():
|
||||
if isinstance(value, str | int | float | bool) or value is None:
|
||||
normalized[key] = value
|
||||
continue
|
||||
raise WorkflowAgentPluginToolsBuildError(
|
||||
"agent_tool_credential_shape_invalid",
|
||||
(
|
||||
f"Dify Plugin Tool {tool_name!r} credential {key!r} has a non-scalar value "
|
||||
f"({type(value).__name__}); only str/int/float/bool/None are forwarded to the daemon."
|
||||
),
|
||||
)
|
||||
return normalized
|
||||
@ -11,13 +11,14 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset(
|
||||
"workflow_context",
|
||||
"model",
|
||||
"structured_output",
|
||||
"tools.dify_tools",
|
||||
}
|
||||
)
|
||||
|
||||
RESERVED_AGENT_BACKEND_FEATURES = frozenset(
|
||||
{
|
||||
"skills_files",
|
||||
"tools",
|
||||
"tools.cli_tools",
|
||||
"knowledge",
|
||||
"human",
|
||||
"env",
|
||||
@ -32,7 +33,7 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
|
||||
warnings: list[dict[str, str]] = []
|
||||
soul_dump = agent_soul.model_dump(mode="json")
|
||||
for section in sorted(RESERVED_AGENT_BACKEND_FEATURES):
|
||||
value = soul_dump.get(section)
|
||||
value = _get_nested(soul_dump, section)
|
||||
has_value = bool(value)
|
||||
if isinstance(value, dict):
|
||||
has_value = any(bool(item) for item in value.values())
|
||||
@ -41,11 +42,12 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
|
||||
{
|
||||
"section": f"agent_soul.{section}",
|
||||
"code": "agent_backend_layer_not_available",
|
||||
"message": f"{section} is saved in Agent Soul but is not executed by Agent backend in phase 3.",
|
||||
"message": f"{section} is saved in Agent Soul but is not executed by Agent backend.",
|
||||
}
|
||||
)
|
||||
|
||||
reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed")
|
||||
reserved_status["tools.dify_tools"] = "supported_when_config_valid"
|
||||
|
||||
return {
|
||||
"supported": sorted(SUPPORTED_AGENT_BACKEND_FEATURES),
|
||||
@ -53,3 +55,12 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
|
||||
"reserved_status": reserved_status,
|
||||
"unsupported_runtime_warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def _get_nested(value: dict[str, Any], path: str) -> Any:
|
||||
current: Any = value
|
||||
for part in path.split("."):
|
||||
if not isinstance(current, dict):
|
||||
return None
|
||||
current = current.get(part)
|
||||
return current
|
||||
|
||||
@ -30,6 +30,7 @@ from models.agent_config_entities import (
|
||||
)
|
||||
|
||||
from .output_failure_orchestrator import retry_idempotency_key
|
||||
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
|
||||
from .runtime_feature_manifest import build_runtime_feature_manifest
|
||||
|
||||
|
||||
@ -84,9 +85,11 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
*,
|
||||
credentials_provider: CredentialsProvider,
|
||||
request_builder: AgentBackendRunRequestBuilder | None = None,
|
||||
plugin_tools_builder: WorkflowAgentPluginToolsBuilder | None = None,
|
||||
) -> None:
|
||||
self._credentials_provider = credentials_provider
|
||||
self._request_builder = request_builder or AgentBackendRunRequestBuilder()
|
||||
self._plugin_tools_builder = plugin_tools_builder or WorkflowAgentPluginToolsBuilder()
|
||||
|
||||
def build(self, context: WorkflowAgentRuntimeBuildContext) -> WorkflowAgentRuntimeRequest:
|
||||
agent_soul = AgentSoulConfig.model_validate(context.snapshot.config_snapshot_dict)
|
||||
@ -102,6 +105,26 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
workflow_job_prompt = node_job.workflow_prompt.strip() or "Run this workflow Agent Node for the current run."
|
||||
user_prompt = workflow_context_prompt.strip() or "Use the current workflow context."
|
||||
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
|
||||
try:
|
||||
tools_layer = self._plugin_tools_builder.build(
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
app_id=context.dify_context.app_id,
|
||||
user_id=context.dify_context.user_id,
|
||||
tools=agent_soul.tools,
|
||||
# Thread the *real* runtime invocation source through to
|
||||
# ToolManager so credential quotas, rate limits, and audit
|
||||
# trails match the actual call site (DEBUGGER for draft test
|
||||
# run, SERVICE_API / WEB_APP for published run).
|
||||
invoke_from=context.dify_context.invoke_from,
|
||||
)
|
||||
except WorkflowAgentPluginToolsBuildError as error:
|
||||
raise WorkflowAgentRuntimeRequestBuildError(error.error_code, str(error)) from error
|
||||
if tools_layer is not None:
|
||||
metadata["agent_tools"] = {
|
||||
"dify_tool_count": len(tools_layer.tools),
|
||||
"dify_tool_names": [tool.name or tool.tool_name for tool in tools_layer.tools],
|
||||
"cli_tool_count": len(agent_soul.tools.cli_tools),
|
||||
}
|
||||
|
||||
request = self._request_builder.build_for_workflow_node(
|
||||
AgentBackendWorkflowNodeRunInput(
|
||||
@ -134,6 +157,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
workflow_node_job_prompt=workflow_job_prompt,
|
||||
user_prompt=user_prompt,
|
||||
output=self._build_output_config(node_job.declared_outputs),
|
||||
tools=tools_layer,
|
||||
idempotency_key=self._idempotency_key(context),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
@ -126,6 +126,7 @@ class WorkflowAgentNodeValidator:
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Workflow Agent node {binding.node_id} requires Agent Soul model config."
|
||||
)
|
||||
cls._validate_agent_soul_tools(binding=binding, agent_soul=agent_soul)
|
||||
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
|
||||
cls.validate_node_job(session=session, binding=binding, node_job=node_job, topology=topology)
|
||||
|
||||
@ -280,6 +281,26 @@ class WorkflowAgentNodeValidator:
|
||||
f"Workflow Agent node {binding.node_id} references unsupported human contact channel {channel}."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _validate_agent_soul_tools(
|
||||
cls,
|
||||
*,
|
||||
binding: WorkflowAgentNodeBinding,
|
||||
agent_soul: AgentSoulConfig,
|
||||
) -> None:
|
||||
exposed_names: set[str] = set()
|
||||
for tool in agent_soul.tools.dify_tools:
|
||||
if not tool.enabled:
|
||||
continue
|
||||
exposed_name = tool.tool_name
|
||||
if exposed_name in exposed_names:
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Workflow Agent node {binding.node_id} has duplicate Dify Plugin Tool name {exposed_name}."
|
||||
)
|
||||
exposed_names.add(exposed_name)
|
||||
# CLI tools remain saved-but-not-executed. They are allowed at publish
|
||||
# time so existing Agent Soul drafts are not blocked by a reserved field.
|
||||
|
||||
@staticmethod
|
||||
def _validate_file_ref(
|
||||
*,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import re
|
||||
from enum import StrEnum
|
||||
from typing import Any, Final
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
@ -50,8 +50,90 @@ class AgentSoulSkillsFilesConfig(BaseModel):
|
||||
skills: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentSoulDifyToolCredentialRef(BaseModel):
|
||||
"""Reference to a stored Dify Plugin Tool credential.
|
||||
|
||||
Secret values are resolved only at runtime. The legacy ``credential_id``
|
||||
field is accepted by :class:`AgentSoulDifyToolConfig` and normalized here so
|
||||
old Agent tool payloads can be read while new payloads stay explicit.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
type: Literal["provider", "tool"] = "tool"
|
||||
id: str | None = Field(default=None, max_length=255)
|
||||
provider: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class AgentSoulDifyToolConfig(BaseModel):
|
||||
"""One Dify Plugin Tool configured on Agent Soul.
|
||||
|
||||
The API backend prepares this persisted product shape into
|
||||
``DifyPluginToolConfig`` before sending a run request to Agent backend.
|
||||
``provider_id`` keeps compatibility with existing Agent tool config payloads;
|
||||
new callers should send ``plugin_id`` + ``provider`` when available.
|
||||
"""
|
||||
|
||||
# ``extra="ignore"`` (not ``"allow"``) so historical Agent Soul payloads
|
||||
# with unknown fields still load — but the extra keys are dropped instead
|
||||
# of silently riding along into ``model_dump``. New callers should send the
|
||||
# explicit schema fields below.
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
enabled: bool = True
|
||||
# Dify Plugin Tools live behind the ``PLUGIN`` provider type. ``BUILT_IN`` /
|
||||
# ``WORKFLOW`` / ``API`` providers are not exposed to the Agent backend in
|
||||
# this layer — keep the default narrow so a missing field surfaces as
|
||||
# ``agent_tool_declaration_not_found`` against the correct provider table.
|
||||
provider_type: str = "plugin"
|
||||
provider_id: str | None = Field(default=None, max_length=255)
|
||||
plugin_id: str | None = Field(default=None, max_length=255)
|
||||
provider: str | None = Field(default=None, max_length=255)
|
||||
tool_name: str = Field(min_length=1, max_length=255)
|
||||
credential_type: Literal["api-key", "oauth2", "unauthorized"] = "api-key"
|
||||
credential_ref: AgentSoulDifyToolCredentialRef | None = None
|
||||
# Reserved for a future user-rename UX. Accepted but currently rejected at
|
||||
# validation time so frontend cannot silently believe a rename took effect
|
||||
# (see :meth:`_validate_provider_and_credentials`).
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = None
|
||||
runtime_parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _normalize_legacy_payload(cls, value: Any) -> Any:
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
normalized = dict(value)
|
||||
if normalized.get("provider_id") is None and isinstance(normalized.get("provider_name"), str):
|
||||
normalized["provider_id"] = normalized["provider_name"]
|
||||
if normalized.get("runtime_parameters") is None and isinstance(normalized.get("tool_parameters"), dict):
|
||||
normalized["runtime_parameters"] = normalized["tool_parameters"]
|
||||
if normalized.get("credential_ref") is None and normalized.get("credential_id"):
|
||||
normalized["credential_ref"] = {
|
||||
"type": "tool",
|
||||
"id": normalized.get("credential_id"),
|
||||
"provider": normalized.get("provider_id") or normalized.get("provider"),
|
||||
}
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_provider_and_credentials(self) -> "AgentSoulDifyToolConfig":
|
||||
if not self.provider_id and not (self.plugin_id and self.provider):
|
||||
raise ValueError("Dify tool requires provider_id or plugin_id + provider")
|
||||
if self.credential_type != "unauthorized" and (self.credential_ref is None or not self.credential_ref.id):
|
||||
raise ValueError("credential_ref.id is required for credentialed Dify tools")
|
||||
# ``name`` is reserved for a future user-rename UX. Until that lands
|
||||
# the model-visible name is forced to match ``tool_name``; reject
|
||||
# explicit values so a frontend bug surfaces immediately instead of
|
||||
# producing a silently-ignored override.
|
||||
if self.name is not None and self.name != self.tool_name:
|
||||
raise ValueError("name override is not yet supported; omit ``name`` or set it equal to ``tool_name``.")
|
||||
return self
|
||||
|
||||
|
||||
class AgentSoulToolsConfig(BaseModel):
|
||||
dify_tools: list[dict[str, Any]] = Field(default_factory=list)
|
||||
dify_tools: list[AgentSoulDifyToolConfig] = Field(default_factory=list)
|
||||
cli_tools: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
@ -10705,6 +10705,43 @@ Supported icon storage formats for Agent roster entries.
|
||||
| skills_files | [AgentSoulSkillsFilesConfig](#agentsoulskillsfilesconfig) | | No |
|
||||
| tools | [AgentSoulToolsConfig](#agentsoultoolsconfig) | | No |
|
||||
|
||||
#### AgentSoulDifyToolConfig
|
||||
|
||||
One Dify Plugin Tool configured on Agent Soul.
|
||||
|
||||
The API backend prepares this persisted product shape into
|
||||
``DifyPluginToolConfig`` before sending a run request to Agent backend.
|
||||
``provider_id`` keeps compatibility with existing Agent tool config payloads;
|
||||
new callers should send ``plugin_id`` + ``provider`` when available.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| credential_ref | [AgentSoulDifyToolCredentialRef](#agentsouldifytoolcredentialref) | | No |
|
||||
| credential_type | string | *Enum:* `"api-key"`, `"oauth2"`, `"unauthorized"` | No |
|
||||
| description | string | | No |
|
||||
| enabled | boolean | | No |
|
||||
| name | string | | No |
|
||||
| plugin_id | string | | No |
|
||||
| provider | string | | No |
|
||||
| provider_id | string | | No |
|
||||
| provider_type | string | | No |
|
||||
| runtime_parameters | object | | No |
|
||||
| tool_name | string | | Yes |
|
||||
|
||||
#### AgentSoulDifyToolCredentialRef
|
||||
|
||||
Reference to a stored Dify Plugin Tool credential.
|
||||
|
||||
Secret values are resolved only at runtime. The legacy ``credential_id``
|
||||
field is accepted by :class:`AgentSoulDifyToolConfig` and normalized here so
|
||||
old Agent tool payloads can be read while new payloads stay explicit.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| id | string | | No |
|
||||
| provider | string | | No |
|
||||
| type | string | *Enum:* `"provider"`, `"tool"` | No |
|
||||
|
||||
#### AgentSoulEnvConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -10782,7 +10819,7 @@ Reference to model credentials resolved only at runtime.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| cli_tools | [ object ] | | No |
|
||||
| dify_tools | [ object ] | | No |
|
||||
| dify_tools | [ [AgentSoulDifyToolConfig](#agentsouldifytoolconfig) ] | | No |
|
||||
|
||||
#### AgentThought
|
||||
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
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_LLM_LAYER_TYPE_ID
|
||||
from dify_agent.layers.dify_plugin import (
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
DifyPluginToolConfig,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
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 (
|
||||
@ -14,6 +19,7 @@ from pydantic import ValidationError
|
||||
from clients.agent_backend import (
|
||||
AGENT_SOUL_PROMPT_LAYER_ID,
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
DIFY_PLUGIN_TOOLS_LAYER_ID,
|
||||
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
|
||||
WORKFLOW_USER_PROMPT_LAYER_ID,
|
||||
AgentBackendModelConfig,
|
||||
@ -100,6 +106,33 @@ def test_request_builder_sets_model_and_output_layer_contract_ids():
|
||||
assert layers[DIFY_AGENT_OUTPUT_LAYER_ID].type == DIFY_OUTPUT_LAYER_TYPE_ID
|
||||
|
||||
|
||||
def test_request_builder_adds_dify_plugin_tools_layer_when_configured():
|
||||
run_input = _run_input()
|
||||
run_input.tools = DifyPluginToolsLayerConfig(
|
||||
tools=[
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/time",
|
||||
provider="time",
|
||||
tool_name="current_time",
|
||||
credential_type="unauthorized",
|
||||
name="current_time",
|
||||
description="Get current time.",
|
||||
credentials={},
|
||||
runtime_parameters={},
|
||||
parameters=[],
|
||||
parameters_json_schema={"type": "object", "properties": {}, "required": []},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input)
|
||||
layers = {layer.name: layer for layer in request.composition.layers}
|
||||
|
||||
assert layers[DIFY_PLUGIN_TOOLS_LAYER_ID].type == DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID
|
||||
assert layers[DIFY_PLUGIN_TOOLS_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
assert layers[DIFY_PLUGIN_TOOLS_LAYER_ID].config.tools[0].tool_name == "current_time"
|
||||
|
||||
|
||||
def test_request_builder_can_suspend_on_exit_for_resume_or_babysit_paths():
|
||||
run_input = _run_input()
|
||||
run_input.suspend_on_exit = True
|
||||
|
||||
@ -0,0 +1,439 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from core.agent.entities import AgentToolEntity
|
||||
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 (
|
||||
ToolDescription,
|
||||
ToolEntity,
|
||||
ToolIdentity,
|
||||
ToolInvokeMessage,
|
||||
ToolParameter,
|
||||
)
|
||||
from core.workflow.nodes.agent_v2.plugin_tools_builder import (
|
||||
WorkflowAgentPluginToolsBuilder,
|
||||
WorkflowAgentPluginToolsBuildError,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulToolsConfig
|
||||
|
||||
|
||||
class FakeRuntimeProvider:
|
||||
def __init__(self, tool: Tool | Exception) -> None:
|
||||
# Either a Tool to hand back, or an exception to raise on lookup. The
|
||||
# latter lets tests exercise the error-mapping branches in
|
||||
# ``WorkflowAgentPluginToolsBuilder._fetch_tool_runtime``.
|
||||
self.tool = tool
|
||||
self.last_agent_tool: AgentToolEntity | None = None
|
||||
self.last_invoke_from: InvokeFrom | None = None
|
||||
|
||||
def get_agent_tool_runtime(
|
||||
self,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
agent_tool: AgentToolEntity,
|
||||
user_id: str | None = None,
|
||||
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
|
||||
variable_pool: Any | None = None,
|
||||
) -> Tool:
|
||||
self.last_agent_tool = agent_tool
|
||||
self.last_invoke_from = invoke_from
|
||||
if isinstance(self.tool, Exception):
|
||||
raise self.tool
|
||||
return self.tool
|
||||
|
||||
|
||||
class FakeTool(Tool):
|
||||
def tool_provider_type(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _invoke(
|
||||
self,
|
||||
user_id: str,
|
||||
tool_parameters: dict[str, Any],
|
||||
conversation_id: str | None = None,
|
||||
app_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> ToolInvokeMessage | list[ToolInvokeMessage] | Generator[ToolInvokeMessage, None, None]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def _tool(*, runtime_parameters: dict[str, Any] | None = None) -> FakeTool:
|
||||
if runtime_parameters is None:
|
||||
runtime_parameters = {"region": "us"}
|
||||
parameters = [
|
||||
ToolParameter(
|
||||
name="query",
|
||||
label=I18nObject(en_US="Query"),
|
||||
type=ToolParameter.ToolParameterType.STRING,
|
||||
form=ToolParameter.ToolParameterForm.LLM,
|
||||
required=True,
|
||||
llm_description="Search query",
|
||||
),
|
||||
ToolParameter(
|
||||
name="region",
|
||||
label=I18nObject(en_US="Region"),
|
||||
type=ToolParameter.ToolParameterType.STRING,
|
||||
form=ToolParameter.ToolParameterForm.FORM,
|
||||
required=True,
|
||||
),
|
||||
]
|
||||
entity = ToolEntity(
|
||||
identity=ToolIdentity(
|
||||
author="langgenius",
|
||||
name="search",
|
||||
label=I18nObject(en_US="Search"),
|
||||
provider="search",
|
||||
),
|
||||
description=ToolDescription(human=I18nObject(en_US="Search"), llm="Search the web."),
|
||||
parameters=parameters,
|
||||
)
|
||||
runtime = ToolRuntime(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
credentials={"api_key": "secret"},
|
||||
runtime_parameters=runtime_parameters,
|
||||
)
|
||||
return FakeTool(entity=entity, runtime=runtime)
|
||||
|
||||
|
||||
def _build(
|
||||
builder: WorkflowAgentPluginToolsBuilder,
|
||||
tools: AgentSoulToolsConfig,
|
||||
*,
|
||||
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
|
||||
):
|
||||
"""Shorthand for ``builder.build(...)`` with the standard tenant/app/user
|
||||
triple, so each test only highlights what's actually unique to it."""
|
||||
return builder.build(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
user_id="user-1",
|
||||
tools=tools,
|
||||
invoke_from=invoke_from,
|
||||
)
|
||||
|
||||
|
||||
def test_builds_dify_plugin_tools_layer_from_existing_tool_runtime():
|
||||
runtime_provider = FakeRuntimeProvider(_tool())
|
||||
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider)
|
||||
tools = AgentSoulToolsConfig.model_validate(
|
||||
{
|
||||
"dify_tools": [
|
||||
{
|
||||
"provider_id": "langgenius/search/search",
|
||||
"tool_name": "search",
|
||||
"credential_type": "api-key",
|
||||
"credential_id": "credential-1",
|
||||
"runtime_parameters": {"region": "us"},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result = _build(builder, tools)
|
||||
|
||||
assert result is not None
|
||||
prepared = result.tools[0]
|
||||
assert prepared.plugin_id == "langgenius/search"
|
||||
assert prepared.provider == "search"
|
||||
assert prepared.tool_name == "search"
|
||||
assert prepared.name == "search"
|
||||
assert prepared.credentials == {"api_key": "secret"}
|
||||
assert prepared.runtime_parameters == {"region": "us"}
|
||||
assert prepared.parameters_json_schema["properties"]["query"]["type"] == "string"
|
||||
assert "region" not in prepared.parameters_json_schema["properties"]
|
||||
assert runtime_provider.last_agent_tool is not None
|
||||
assert runtime_provider.last_agent_tool.credential_id == "credential-1"
|
||||
# Default ``provider_type`` is now ``"plugin"`` — the agent tool entity
|
||||
# must surface that so ToolManager hits the plugin provider table, not the
|
||||
# built-in legacy table.
|
||||
assert runtime_provider.last_agent_tool.provider_type.value == "plugin"
|
||||
|
||||
|
||||
def test_rejects_duplicate_exposed_tool_names():
|
||||
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_tool()))
|
||||
tools = AgentSoulToolsConfig.model_validate(
|
||||
{
|
||||
"dify_tools": [
|
||||
{
|
||||
"provider_id": "langgenius/search/search",
|
||||
"tool_name": "search",
|
||||
"credential_type": "api-key",
|
||||
"credential_id": "credential-1",
|
||||
"runtime_parameters": {"region": "us"},
|
||||
},
|
||||
{
|
||||
"provider_id": "langgenius/search/search",
|
||||
"tool_name": "search",
|
||||
"credential_type": "api-key",
|
||||
"credential_id": "credential-1",
|
||||
"runtime_parameters": {"region": "us"},
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info:
|
||||
_build(builder, tools)
|
||||
|
||||
assert exc_info.value.error_code == "agent_tool_name_duplicated"
|
||||
|
||||
|
||||
def test_rejects_missing_required_runtime_parameter():
|
||||
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_tool(runtime_parameters={})))
|
||||
tools = AgentSoulToolsConfig.model_validate(
|
||||
{
|
||||
"dify_tools": [
|
||||
{
|
||||
"provider_id": "langgenius/search/search",
|
||||
"tool_name": "search",
|
||||
"credential_type": "api-key",
|
||||
"credential_id": "credential-1",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info:
|
||||
_build(builder, tools)
|
||||
|
||||
assert exc_info.value.error_code == "agent_tool_runtime_parameter_missing"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# invoke_from is threaded through to ToolManager
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_invoke_from_is_forwarded_to_tool_runtime_provider():
|
||||
"""``WorkflowAgentRuntimeRequestBuilder`` passes the *real* runtime
|
||||
invocation source (DEBUGGER for draft test run, SERVICE_API for published
|
||||
run, etc.). ToolManager uses ``invoke_from`` for credential quotas / rate
|
||||
limits / audit tags, so any default-falling-back here would silently
|
||||
misattribute usage. Lock in the forwarding behaviour for both
|
||||
representative invoke_from values."""
|
||||
for invoke_from in (InvokeFrom.DEBUGGER, InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP):
|
||||
runtime_provider = FakeRuntimeProvider(_tool())
|
||||
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider)
|
||||
tools = AgentSoulToolsConfig.model_validate(
|
||||
{
|
||||
"dify_tools": [
|
||||
{
|
||||
"provider_id": "langgenius/search/search",
|
||||
"tool_name": "search",
|
||||
"credential_type": "api-key",
|
||||
"credential_id": "credential-1",
|
||||
"runtime_parameters": {"region": "us"},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
_build(builder, tools, invoke_from=invoke_from)
|
||||
|
||||
assert runtime_provider.last_invoke_from == invoke_from
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# disabled tools / plugin_id+provider fallback / unauthorized credentials
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_disabled_tools_are_skipped():
|
||||
runtime_provider = FakeRuntimeProvider(_tool())
|
||||
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider)
|
||||
tools = AgentSoulToolsConfig.model_validate(
|
||||
{
|
||||
"dify_tools": [
|
||||
{
|
||||
"provider_id": "langgenius/search/search",
|
||||
"tool_name": "search",
|
||||
"credential_type": "api-key",
|
||||
"credential_id": "credential-1",
|
||||
"runtime_parameters": {"region": "us"},
|
||||
"enabled": False,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# All entries are disabled → builder short-circuits and returns None so the
|
||||
# request_builder skips adding the tools layer entirely.
|
||||
assert _build(builder, tools) is None
|
||||
assert runtime_provider.last_agent_tool is None # ToolManager never queried
|
||||
|
||||
|
||||
def test_plugin_id_plus_provider_fallback_when_provider_id_missing():
|
||||
"""Frontend may send ``plugin_id`` + ``provider`` instead of the
|
||||
concatenated ``provider_id``; the builder must accept both shapes."""
|
||||
runtime_provider = FakeRuntimeProvider(_tool())
|
||||
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider)
|
||||
tools = AgentSoulToolsConfig.model_validate(
|
||||
{
|
||||
"dify_tools": [
|
||||
{
|
||||
"plugin_id": "langgenius/search",
|
||||
"provider": "search",
|
||||
"tool_name": "search",
|
||||
"credential_type": "api-key",
|
||||
"credential_id": "credential-1",
|
||||
"runtime_parameters": {"region": "us"},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result = _build(builder, tools)
|
||||
|
||||
assert result is not None
|
||||
assert runtime_provider.last_agent_tool is not None
|
||||
assert runtime_provider.last_agent_tool.provider_id == "langgenius/search/search"
|
||||
assert result.tools[0].plugin_id == "langgenius/search"
|
||||
assert result.tools[0].provider == "search"
|
||||
|
||||
|
||||
def test_unauthorized_tool_without_credentials():
|
||||
"""``credential_type=unauthorized`` removes the ``credential_ref.id``
|
||||
requirement (e.g. public Wikipedia / current_time tools)."""
|
||||
|
||||
def _no_credentials_tool() -> FakeTool:
|
||||
tool = _tool()
|
||||
assert tool.runtime is not None
|
||||
tool.runtime.credentials = {}
|
||||
return tool
|
||||
|
||||
runtime_provider = FakeRuntimeProvider(_no_credentials_tool())
|
||||
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider)
|
||||
tools = AgentSoulToolsConfig.model_validate(
|
||||
{
|
||||
"dify_tools": [
|
||||
{
|
||||
"provider_id": "langgenius/time/time",
|
||||
"tool_name": "current_time",
|
||||
"credential_type": "unauthorized",
|
||||
"runtime_parameters": {"region": "us"},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result = _build(builder, tools)
|
||||
assert result is not None
|
||||
assert result.tools[0].credential_type == "unauthorized"
|
||||
assert result.tools[0].credentials == {}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Error-code mapping: declaration not found / credential invalid / config
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _standard_tools_payload() -> AgentSoulToolsConfig:
|
||||
return AgentSoulToolsConfig.model_validate(
|
||||
{
|
||||
"dify_tools": [
|
||||
{
|
||||
"provider_id": "langgenius/search/search",
|
||||
"tool_name": "search",
|
||||
"credential_type": "api-key",
|
||||
"credential_id": "credential-1",
|
||||
"runtime_parameters": {"region": "us"},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_tool_provider_not_found_maps_to_declaration_not_found():
|
||||
from core.tools.errors import ToolProviderNotFoundError
|
||||
|
||||
builder = WorkflowAgentPluginToolsBuilder(
|
||||
tool_runtime_provider=FakeRuntimeProvider(ToolProviderNotFoundError("provider gone"))
|
||||
)
|
||||
with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info:
|
||||
_build(builder, _standard_tools_payload())
|
||||
assert exc_info.value.error_code == "agent_tool_declaration_not_found"
|
||||
|
||||
|
||||
def test_credential_validation_error_maps_to_credential_invalid():
|
||||
from core.tools.errors import ToolProviderCredentialValidationError
|
||||
|
||||
builder = WorkflowAgentPluginToolsBuilder(
|
||||
tool_runtime_provider=FakeRuntimeProvider(ToolProviderCredentialValidationError("creds expired"))
|
||||
)
|
||||
with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info:
|
||||
_build(builder, _standard_tools_payload())
|
||||
assert exc_info.value.error_code == "agent_tool_credential_invalid"
|
||||
|
||||
|
||||
def test_generic_value_error_maps_to_config_invalid():
|
||||
"""Bare ``ValueError`` from ToolManager (e.g. "runtime not found") becomes
|
||||
``agent_tool_config_invalid`` — distinct from
|
||||
``agent_tool_declaration_not_found`` so callers can render a different
|
||||
hint."""
|
||||
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(ValueError("runtime missing")))
|
||||
with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info:
|
||||
_build(builder, _standard_tools_payload())
|
||||
assert exc_info.value.error_code == "agent_tool_config_invalid"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Non-scalar credentials rejected instead of silently str()'d
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_rejects_non_scalar_credential_value():
|
||||
"""If a credential ever shows up shaped like ``{"access_token": "..."}``,
|
||||
``str(value)`` would forward a Python repr to the plugin daemon. The
|
||||
builder should refuse and surface an explicit error code so an operator
|
||||
fixes the credential schema instead of debugging a daemon JSON parse
|
||||
failure."""
|
||||
|
||||
def _dict_credential_tool() -> FakeTool:
|
||||
tool = _tool()
|
||||
assert tool.runtime is not None
|
||||
tool.runtime.credentials = {"oauth": {"access_token": "secret", "expires_in": 3600}}
|
||||
return tool
|
||||
|
||||
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_dict_credential_tool()))
|
||||
with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info:
|
||||
_build(builder, _standard_tools_payload())
|
||||
assert exc_info.value.error_code == "agent_tool_credential_shape_invalid"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Legacy payload normalization
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_legacy_provider_name_and_tool_parameters_normalized():
|
||||
"""Old Composer save payloads used ``provider_name`` / ``tool_parameters``
|
||||
keys. The ``@model_validator(mode="before")`` on AgentSoulDifyToolConfig
|
||||
rewrites them in-place so reading historical Agent Soul snapshots from the
|
||||
DB still works."""
|
||||
config = AgentSoulToolsConfig.model_validate(
|
||||
{
|
||||
"dify_tools": [
|
||||
{
|
||||
"provider_name": "langgenius/search/search",
|
||||
"tool_name": "search",
|
||||
"credential_type": "api-key",
|
||||
"credential_id": "credential-1",
|
||||
"tool_parameters": {"region": "us"},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
tool = config.dify_tools[0]
|
||||
assert tool.provider_id == "langgenius/search/search"
|
||||
assert tool.runtime_parameters == {"region": "us"}
|
||||
assert tool.credential_ref is not None
|
||||
assert tool.credential_ref.id == "credential-1"
|
||||
@ -1,8 +1,9 @@
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig
|
||||
|
||||
from clients.agent_backend import DIFY_EXECUTION_CONTEXT_LAYER_ID
|
||||
from clients.agent_backend import DIFY_EXECUTION_CONTEXT_LAYER_ID, DIFY_PLUGIN_TOOLS_LAYER_ID
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom
|
||||
from core.workflow.nodes.agent_v2.runtime_request_builder import (
|
||||
WorkflowAgentRuntimeBuildContext,
|
||||
@ -26,6 +27,38 @@ class FakeCredentialsProvider:
|
||||
return {"api_key": "secret-key"}
|
||||
|
||||
|
||||
class FakePluginToolsBuilder:
|
||||
def __init__(self) -> None:
|
||||
# Capture the runtime invocation source so tests can assert it was
|
||||
# threaded through from ``DifyRunContext.invoke_from`` rather than
|
||||
# hard-coded to a placeholder like ``VALIDATION``.
|
||||
self.last_invoke_from: InvokeFrom | None = None
|
||||
|
||||
def build(self, *, tenant_id, app_id, user_id, tools, invoke_from):
|
||||
assert tenant_id == "tenant-1"
|
||||
assert app_id == "app-1"
|
||||
assert user_id == "user-1"
|
||||
self.last_invoke_from = invoke_from
|
||||
if not tools.dify_tools:
|
||||
return None
|
||||
return DifyPluginToolsLayerConfig(
|
||||
tools=[
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/time",
|
||||
provider="time",
|
||||
tool_name="current_time",
|
||||
credential_type="unauthorized",
|
||||
name="current_time",
|
||||
description="Get current time.",
|
||||
credentials={},
|
||||
runtime_parameters={},
|
||||
parameters=[],
|
||||
parameters_json_schema={"type": "object", "properties": {}, "required": []},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class FakeVariablePool:
|
||||
def get(self, selector):
|
||||
if list(selector) == ["sys", "query"]:
|
||||
@ -155,8 +188,60 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada
|
||||
assert output_schema["properties"]["confidence"]["type"] == "number"
|
||||
assert output_schema["required"] == ["report"]
|
||||
assert dumped["composition"]["layers"][4]["config"]["model_settings"] == {"temperature": 0.2}
|
||||
assert result.metadata["runtime_support"]["reserved_status"]["tools"] == "reserved_not_executed"
|
||||
assert result.metadata["runtime_support"]["unsupported_runtime_warnings"][0]["section"] == "agent_soul.tools"
|
||||
assert result.metadata["runtime_support"]["reserved_status"]["tools.dify_tools"] == "supported_when_config_valid"
|
||||
assert result.metadata["runtime_support"]["reserved_status"]["tools.cli_tools"] == "reserved_not_executed"
|
||||
warnings = result.metadata["runtime_support"]["unsupported_runtime_warnings"]
|
||||
assert warnings[0]["section"] == "agent_soul.tools.cli_tools"
|
||||
|
||||
|
||||
def test_builds_workflow_run_request_with_dify_plugin_tools_layer():
|
||||
context = _context()
|
||||
snapshot = AgentConfigSnapshot(
|
||||
id="snapshot-1",
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
version=1,
|
||||
config_snapshot=AgentSoulConfig(
|
||||
prompt={"system_prompt": "You are careful."},
|
||||
model=AgentSoulModelConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
tools={
|
||||
"dify_tools": [
|
||||
{
|
||||
"provider_id": "langgenius/time/time",
|
||||
"tool_name": "current_time",
|
||||
"credential_type": "unauthorized",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
)
|
||||
context = replace(context, snapshot=snapshot)
|
||||
|
||||
plugin_tools_builder = FakePluginToolsBuilder()
|
||||
result = WorkflowAgentRuntimeRequestBuilder(
|
||||
credentials_provider=FakeCredentialsProvider(),
|
||||
plugin_tools_builder=plugin_tools_builder,
|
||||
).build(context)
|
||||
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]}
|
||||
assert layers[DIFY_PLUGIN_TOOLS_LAYER_ID]["type"] == "dify.plugin.tools"
|
||||
assert layers[DIFY_PLUGIN_TOOLS_LAYER_ID]["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
assert layers[DIFY_PLUGIN_TOOLS_LAYER_ID]["config"]["tools"][0]["tool_name"] == "current_time"
|
||||
assert result.metadata["agent_tools"] == {
|
||||
"dify_tool_count": 1,
|
||||
"dify_tool_names": ["current_time"],
|
||||
"cli_tool_count": 0,
|
||||
}
|
||||
# The runtime invocation source must flow from ``DifyRunContext.invoke_from``
|
||||
# into the plugin tools builder so ToolManager attributes credential
|
||||
# quotas / rate limits / audit tags to the real call site instead of a
|
||||
# hard-coded ``VALIDATION`` placeholder.
|
||||
assert plugin_tools_builder.last_invoke_from == context.dify_context.invoke_from
|
||||
|
||||
|
||||
def test_requires_agent_soul_model_config():
|
||||
|
||||
@ -121,9 +121,7 @@ export type AgentSoulToolsConfig = {
|
||||
cli_tools?: Array<{
|
||||
[key: string]: unknown
|
||||
}>
|
||||
dify_tools?: Array<{
|
||||
[key: string]: unknown
|
||||
}>
|
||||
dify_tools?: Array<AgentSoulDifyToolConfig>
|
||||
}
|
||||
|
||||
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
|
||||
@ -134,6 +132,28 @@ export type AgentSoulModelCredentialRef = {
|
||||
type: string
|
||||
}
|
||||
|
||||
export type AgentSoulDifyToolConfig = {
|
||||
credential_ref?: AgentSoulDifyToolCredentialRef
|
||||
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
name?: string | null
|
||||
plugin_id?: string | null
|
||||
provider?: string | null
|
||||
provider_id?: string | null
|
||||
provider_type?: string
|
||||
runtime_parameters?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
tool_name: string
|
||||
}
|
||||
|
||||
export type AgentSoulDifyToolCredentialRef = {
|
||||
id?: string | null
|
||||
provider?: string | null
|
||||
type?: 'provider' | 'tool'
|
||||
}
|
||||
|
||||
export type GetAgentsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@ -78,14 +78,6 @@ export const zAgentSoulSkillsFilesConfig = z.object({
|
||||
skills: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulToolsConfig
|
||||
*/
|
||||
export const zAgentSoulToolsConfig = z.object({
|
||||
cli_tools: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
dify_tools: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeQueryMode
|
||||
*/
|
||||
@ -124,6 +116,53 @@ export const zAgentSoulModelConfig = z.object({
|
||||
plugin_id: z.string().min(1).max(255),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulDifyToolCredentialRef
|
||||
*
|
||||
* Reference to a stored Dify Plugin Tool credential.
|
||||
*
|
||||
* Secret values are resolved only at runtime. The legacy ``credential_id``
|
||||
* field is accepted by :class:`AgentSoulDifyToolConfig` and normalized here so
|
||||
* old Agent tool payloads can be read while new payloads stay explicit.
|
||||
*/
|
||||
export const zAgentSoulDifyToolCredentialRef = z.object({
|
||||
id: z.string().max(255).nullish(),
|
||||
provider: z.string().max(255).nullish(),
|
||||
type: z.enum(['provider', 'tool']).optional().default('tool'),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulDifyToolConfig
|
||||
*
|
||||
* One Dify Plugin Tool configured on Agent Soul.
|
||||
*
|
||||
* The API backend prepares this persisted product shape into
|
||||
* ``DifyPluginToolConfig`` before sending a run request to Agent backend.
|
||||
* ``provider_id`` keeps compatibility with existing Agent tool config payloads;
|
||||
* new callers should send ``plugin_id`` + ``provider`` when available.
|
||||
*/
|
||||
export const zAgentSoulDifyToolConfig = z.object({
|
||||
credential_ref: zAgentSoulDifyToolCredentialRef.optional(),
|
||||
credential_type: z.enum(['api-key', 'oauth2', 'unauthorized']).optional().default('api-key'),
|
||||
description: z.string().nullish(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
name: z.string().max(255).nullish(),
|
||||
plugin_id: z.string().max(255).nullish(),
|
||||
provider: z.string().max(255).nullish(),
|
||||
provider_id: z.string().max(255).nullish(),
|
||||
provider_type: z.string().optional().default('plugin'),
|
||||
runtime_parameters: z.record(z.string(), z.unknown()).optional(),
|
||||
tool_name: z.string().min(1).max(255),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulToolsConfig
|
||||
*/
|
||||
export const zAgentSoulToolsConfig = z.object({
|
||||
cli_tools: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
dify_tools: z.array(zAgentSoulDifyToolConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulConfig
|
||||
*/
|
||||
|
||||
@ -1430,9 +1430,7 @@ export type AgentSoulToolsConfig = {
|
||||
cli_tools?: Array<{
|
||||
[key: string]: unknown
|
||||
}>
|
||||
dify_tools?: Array<{
|
||||
[key: string]: unknown
|
||||
}>
|
||||
dify_tools?: Array<AgentSoulDifyToolConfig>
|
||||
}
|
||||
|
||||
export type DeclaredOutputConfig = {
|
||||
@ -1525,6 +1523,22 @@ export type AgentSoulModelCredentialRef = {
|
||||
type: string
|
||||
}
|
||||
|
||||
export type AgentSoulDifyToolConfig = {
|
||||
credential_ref?: AgentSoulDifyToolCredentialRef
|
||||
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
name?: string | null
|
||||
plugin_id?: string | null
|
||||
provider?: string | null
|
||||
provider_id?: string | null
|
||||
provider_type?: string
|
||||
runtime_parameters?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
tool_name: string
|
||||
}
|
||||
|
||||
export type DeclaredArrayItem = {
|
||||
description?: string | null
|
||||
type: DeclaredOutputType
|
||||
@ -1562,6 +1576,12 @@ export type UserActionConfig = {
|
||||
|
||||
export type FormInputConfig = unknown
|
||||
|
||||
export type AgentSoulDifyToolCredentialRef = {
|
||||
id?: string | null
|
||||
provider?: string | null
|
||||
type?: 'provider' | 'tool'
|
||||
}
|
||||
|
||||
export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop'
|
||||
|
||||
export type DeclaredOutputRetryConfig = {
|
||||
|
||||
@ -1533,14 +1533,6 @@ export const zAgentSoulSkillsFilesConfig = z.object({
|
||||
skills: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulToolsConfig
|
||||
*/
|
||||
export const zAgentSoulToolsConfig = z.object({
|
||||
cli_tools: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
dify_tools: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowNodeJobMode
|
||||
*/
|
||||
@ -1771,25 +1763,6 @@ export const zAgentSoulModelConfig = z.object({
|
||||
plugin_id: z.string().min(1).max(255),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulConfig
|
||||
*/
|
||||
export const zAgentSoulConfig = z.object({
|
||||
app_features: z.record(z.string(), z.unknown()).optional(),
|
||||
app_variables: z.array(zAppVariableConfig).optional(),
|
||||
env: zAgentSoulEnvConfig.optional(),
|
||||
human: zAgentSoulHumanConfig.optional(),
|
||||
knowledge: zAgentSoulKnowledgeConfig.optional(),
|
||||
memory: zAgentSoulMemoryConfig.optional(),
|
||||
misc_legacy: z.record(z.string(), z.unknown()).optional(),
|
||||
model: zAgentSoulModelConfig.optional(),
|
||||
prompt: zAgentSoulPromptConfig.optional(),
|
||||
sandbox: zAgentSoulSandboxConfig.optional(),
|
||||
schema_version: z.int().optional().default(1),
|
||||
skills_files: zAgentSoulSkillsFilesConfig.optional(),
|
||||
tools: zAgentSoulToolsConfig.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* DeclaredOutputCheckConfig
|
||||
*
|
||||
@ -1842,6 +1815,72 @@ export const zDeclaredArrayItem = z.object({
|
||||
|
||||
export const zFormInputConfig = z.unknown()
|
||||
|
||||
/**
|
||||
* AgentSoulDifyToolCredentialRef
|
||||
*
|
||||
* Reference to a stored Dify Plugin Tool credential.
|
||||
*
|
||||
* Secret values are resolved only at runtime. The legacy ``credential_id``
|
||||
* field is accepted by :class:`AgentSoulDifyToolConfig` and normalized here so
|
||||
* old Agent tool payloads can be read while new payloads stay explicit.
|
||||
*/
|
||||
export const zAgentSoulDifyToolCredentialRef = z.object({
|
||||
id: z.string().max(255).nullish(),
|
||||
provider: z.string().max(255).nullish(),
|
||||
type: z.enum(['provider', 'tool']).optional().default('tool'),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulDifyToolConfig
|
||||
*
|
||||
* One Dify Plugin Tool configured on Agent Soul.
|
||||
*
|
||||
* The API backend prepares this persisted product shape into
|
||||
* ``DifyPluginToolConfig`` before sending a run request to Agent backend.
|
||||
* ``provider_id`` keeps compatibility with existing Agent tool config payloads;
|
||||
* new callers should send ``plugin_id`` + ``provider`` when available.
|
||||
*/
|
||||
export const zAgentSoulDifyToolConfig = z.object({
|
||||
credential_ref: zAgentSoulDifyToolCredentialRef.optional(),
|
||||
credential_type: z.enum(['api-key', 'oauth2', 'unauthorized']).optional().default('api-key'),
|
||||
description: z.string().nullish(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
name: z.string().max(255).nullish(),
|
||||
plugin_id: z.string().max(255).nullish(),
|
||||
provider: z.string().max(255).nullish(),
|
||||
provider_id: z.string().max(255).nullish(),
|
||||
provider_type: z.string().optional().default('plugin'),
|
||||
runtime_parameters: z.record(z.string(), z.unknown()).optional(),
|
||||
tool_name: z.string().min(1).max(255),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulToolsConfig
|
||||
*/
|
||||
export const zAgentSoulToolsConfig = z.object({
|
||||
cli_tools: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
dify_tools: z.array(zAgentSoulDifyToolConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulConfig
|
||||
*/
|
||||
export const zAgentSoulConfig = z.object({
|
||||
app_features: z.record(z.string(), z.unknown()).optional(),
|
||||
app_variables: z.array(zAppVariableConfig).optional(),
|
||||
env: zAgentSoulEnvConfig.optional(),
|
||||
human: zAgentSoulHumanConfig.optional(),
|
||||
knowledge: zAgentSoulKnowledgeConfig.optional(),
|
||||
memory: zAgentSoulMemoryConfig.optional(),
|
||||
misc_legacy: z.record(z.string(), z.unknown()).optional(),
|
||||
model: zAgentSoulModelConfig.optional(),
|
||||
prompt: zAgentSoulPromptConfig.optional(),
|
||||
sandbox: zAgentSoulSandboxConfig.optional(),
|
||||
schema_version: z.int().optional().default(1),
|
||||
skills_files: zAgentSoulSkillsFilesConfig.optional(),
|
||||
tools: zAgentSoulToolsConfig.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* OutputErrorStrategy
|
||||
*
|
||||
|
||||
Loading…
Reference in New Issue
Block a user