mirror of
https://github.com/langgenius/dify.git
synced 2026-05-26 20:07:46 +08:00
add agent backend plugin layer
This commit is contained in:
@ -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(
|
||||
|
||||
201
api/core/workflow/nodes/agent_v2/plugin_tools_builder.py
Normal file
201
api/core/workflow/nodes/agent_v2/plugin_tools_builder.py
Normal 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
|
||||
@ -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,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,
|
||||
)
|
||||
|
||||
@ -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,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)
|
||||
|
||||
|
||||
|
||||
@ -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,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"
|
||||
@ -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():
|
||||
|
||||
Reference in New Issue
Block a user