add agent backend plugin layer

This commit is contained in:
Yansong Zhang
2026-05-26 17:46:40 +08:00
parent fb07b43107
commit a41fa5607b
10 changed files with 628 additions and 9 deletions

View File

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

View File

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

View File

@ -0,0 +1,201 @@
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.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,
) -> DifyPluginToolsLayerConfig | None:
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)
try:
tool_runtime = 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=InvokeFrom.VALIDATION,
variable_pool=None,
)
except Exception as exc:
raise WorkflowAgentPluginToolsBuildError(
"agent_tool_declaration_not_found",
f"Unable to resolve Dify Plugin Tool {tool_config.tool_name!r}: {exc}",
) from exc
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)
@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),
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]) -> dict[str, DifyPluginCredentialValue]:
normalized: dict[str, DifyPluginCredentialValue] = {}
for key, value in credentials.items():
if isinstance(value, str | int | float | bool) or value is None:
normalized[key] = value
else:
normalized[key] = str(value)
return normalized

View File

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

View File

@ -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,21 @@ 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,
)
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 +152,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,
)

View File

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

View File

@ -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,74 @@ 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.
"""
model_config = ConfigDict(extra="allow")
enabled: bool = True
provider_type: str = "builtin"
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
name: str | None = Field(default=None, max_length=255)
description: str | None = None
runtime_parameters: dict[str, Any] = Field(default_factory=dict)
parameter_overrides: 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")
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)

View File

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

View File

@ -0,0 +1,179 @@
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) -> None:
self.tool = tool
self.last_agent_tool: AgentToolEntity | 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
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 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 = builder.build(tenant_id="tenant-1", app_id="app-1", user_id="user-1", tools=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"
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:
builder.build(tenant_id="tenant-1", app_id="app-1", user_id="user-1", tools=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:
builder.build(tenant_id="tenant-1", app_id="app-1", user_id="user-1", tools=tools)
assert exc_info.value.error_code == "agent_tool_runtime_parameter_missing"

View File

@ -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,31 @@ class FakeCredentialsProvider:
return {"api_key": "secret-key"}
class FakePluginToolsBuilder:
def build(self, *, tenant_id, app_id, user_id, tools):
assert tenant_id == "tenant-1"
assert app_id == "app-1"
assert user_id == "user-1"
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 +181,54 @@ 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)
result = WorkflowAgentRuntimeRequestBuilder(
credentials_provider=FakeCredentialsProvider(),
plugin_tools_builder=FakePluginToolsBuilder(),
).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,
}
def test_requires_agent_soul_model_config():