diff --git a/api/clients/agent_backend/__init__.py b/api/clients/agent_backend/__init__.py index b2e0a30944..4d459d34a0 100644 --- a/api/clients/agent_backend/__init__.py +++ b/api/clients/agent_backend/__init__.py @@ -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", diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index c1e7bb4de8..74114469dd 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -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( diff --git a/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py b/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py new file mode 100644 index 0000000000..0dd98f65ed --- /dev/null +++ b/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py @@ -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 diff --git a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py index afd730f652..b08dc36fb7 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py +++ b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py @@ -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 diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index effd6b9d8a..0a0960d493 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -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, ) diff --git a/api/core/workflow/nodes/agent_v2/validators.py b/api/core/workflow/nodes/agent_v2/validators.py index f8df0506e8..768fcdadeb 100644 --- a/api/core/workflow/nodes/agent_v2/validators.py +++ b/api/core/workflow/nodes/agent_v2/validators.py @@ -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( *, diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index 9524d22d7f..ec604115de 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -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) diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index da9b3d7d66..194fd631c3 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -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 diff --git a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py index 6f598e1901..0df3940af8 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py +++ b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py @@ -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 diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py new file mode 100644 index 0000000000..c27b560e45 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py @@ -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" diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 02e5d2fe8a..48ae0d46f2 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -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(): diff --git a/packages/contracts/generated/api/console/agents/types.gen.ts b/packages/contracts/generated/api/console/agents/types.gen.ts index 8a4540f933..e63c57f03b 100644 --- a/packages/contracts/generated/api/console/agents/types.gen.ts +++ b/packages/contracts/generated/api/console/agents/types.gen.ts @@ -121,9 +121,7 @@ export type AgentSoulToolsConfig = { cli_tools?: Array<{ [key: string]: unknown }> - dify_tools?: Array<{ - [key: string]: unknown - }> + dify_tools?: Array } 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 diff --git a/packages/contracts/generated/api/console/agents/zod.gen.ts b/packages/contracts/generated/api/console/agents/zod.gen.ts index f84b5fc411..130144d48d 100644 --- a/packages/contracts/generated/api/console/agents/zod.gen.ts +++ b/packages/contracts/generated/api/console/agents/zod.gen.ts @@ -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 */ diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index fb65c57af3..c62f34bb44 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -1430,9 +1430,7 @@ export type AgentSoulToolsConfig = { cli_tools?: Array<{ [key: string]: unknown }> - dify_tools?: Array<{ - [key: string]: unknown - }> + dify_tools?: Array } 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 = { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index af299d2bcd..2016a802cf 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -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 *