feat: add agent backend plugin layer (#36686)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123 2026-05-27 10:03:51 +08:00 committed by GitHub
parent 58b8fc21d4
commit ebff9a3639
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1186 additions and 51 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,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

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

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

View File

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

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

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

View File

@ -121,9 +121,7 @@ export type AgentSoulToolsConfig = {
cli_tools?: Array<{
[key: string]: unknown
}>
dify_tools?: Array<{
[key: string]: unknown
}>
dify_tools?: Array<AgentSoulDifyToolConfig>
}
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
@ -134,6 +132,28 @@ export type AgentSoulModelCredentialRef = {
type: string
}
export type AgentSoulDifyToolConfig = {
credential_ref?: AgentSoulDifyToolCredentialRef
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'
description?: string | null
enabled?: boolean
name?: string | null
plugin_id?: string | null
provider?: string | null
provider_id?: string | null
provider_type?: string
runtime_parameters?: {
[key: string]: unknown
}
tool_name: string
}
export type AgentSoulDifyToolCredentialRef = {
id?: string | null
provider?: string | null
type?: 'provider' | 'tool'
}
export type GetAgentsData = {
body?: never
path?: never

View File

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

View File

@ -1430,9 +1430,7 @@ export type AgentSoulToolsConfig = {
cli_tools?: Array<{
[key: string]: unknown
}>
dify_tools?: Array<{
[key: string]: unknown
}>
dify_tools?: Array<AgentSoulDifyToolConfig>
}
export type DeclaredOutputConfig = {
@ -1525,6 +1523,22 @@ export type AgentSoulModelCredentialRef = {
type: string
}
export type AgentSoulDifyToolConfig = {
credential_ref?: AgentSoulDifyToolCredentialRef
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'
description?: string | null
enabled?: boolean
name?: string | null
plugin_id?: string | null
provider?: string | null
provider_id?: string | null
provider_type?: string
runtime_parameters?: {
[key: string]: unknown
}
tool_name: string
}
export type DeclaredArrayItem = {
description?: string | null
type: DeclaredOutputType
@ -1562,6 +1576,12 @@ export type UserActionConfig = {
export type FormInputConfig = unknown
export type AgentSoulDifyToolCredentialRef = {
id?: string | null
provider?: string | null
type?: 'provider' | 'tool'
}
export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop'
export type DeclaredOutputRetryConfig = {

View File

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