mirror of
https://github.com/langgenius/dify.git
synced 2026-06-12 11:32:08 +08:00
feat: agent slash menu backend (#37331)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
aff8f82bc0
commit
6cfd96ccd6
@ -45,11 +45,27 @@ class AgentToolRuntimeProvider(Protocol):
|
||||
) -> Tool: ...
|
||||
|
||||
|
||||
class ProviderToolsLister(Protocol):
|
||||
def __call__(self, *, tenant_id: str, provider_id: str) -> list[str]: ...
|
||||
|
||||
|
||||
def _list_provider_tool_names(*, tenant_id: str, provider_id: str) -> list[str]:
|
||||
"""Tool names a provider currently declares (provider-level config entries)."""
|
||||
provider = ToolManager.get_builtin_provider(provider_id, tenant_id)
|
||||
return [tool.entity.identity.name for tool in provider.get_tools() or []]
|
||||
|
||||
|
||||
class WorkflowAgentPluginToolsBuilder:
|
||||
"""Prepare Agent Soul Dify Plugin Tools for the public Agent backend DTO."""
|
||||
|
||||
def __init__(self, *, tool_runtime_provider: AgentToolRuntimeProvider | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
tool_runtime_provider: AgentToolRuntimeProvider | None = None,
|
||||
provider_tools_lister: ProviderToolsLister | None = None,
|
||||
) -> None:
|
||||
self._tool_runtime_provider = tool_runtime_provider or ToolManager
|
||||
self._provider_tools_lister = provider_tools_lister or _list_provider_tool_names
|
||||
|
||||
def build(
|
||||
self,
|
||||
@ -73,7 +89,7 @@ class WorkflowAgentPluginToolsBuilder:
|
||||
|
||||
prepared: list[DifyPluginToolConfig] = []
|
||||
seen_names: set[str] = set()
|
||||
for tool_config in enabled_tools:
|
||||
for tool_config in self._expand_provider_entries(tenant_id=tenant_id, enabled_tools=enabled_tools):
|
||||
agent_tool = self._to_agent_tool_entity(tool_config)
|
||||
tool_runtime = self._fetch_tool_runtime(
|
||||
tenant_id=tenant_id,
|
||||
@ -96,6 +112,48 @@ class WorkflowAgentPluginToolsBuilder:
|
||||
|
||||
return DifyPluginToolsLayerConfig(tools=prepared)
|
||||
|
||||
def _expand_provider_entries(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
enabled_tools: list[AgentSoulDifyToolConfig],
|
||||
) -> list[AgentSoulDifyToolConfig]:
|
||||
"""Expand provider-level entries (``tool_name`` omitted = all tools).
|
||||
|
||||
An explicit per-tool entry of the same provider wins over the expansion
|
||||
(it may carry its own ``runtime_parameters``); expanded clones share the
|
||||
provider entry's ``credential_ref`` and start with default parameters.
|
||||
"""
|
||||
explicit_by_provider: dict[str, set[str]] = {}
|
||||
for tool_config in enabled_tools:
|
||||
if tool_config.tool_name is not None:
|
||||
explicit_by_provider.setdefault(self._provider_id(tool_config), set()).add(tool_config.tool_name)
|
||||
|
||||
expanded: list[AgentSoulDifyToolConfig] = []
|
||||
for tool_config in enabled_tools:
|
||||
if tool_config.tool_name is not None:
|
||||
expanded.append(tool_config)
|
||||
continue
|
||||
provider_id = self._provider_id(tool_config)
|
||||
try:
|
||||
tool_names = self._provider_tools_lister(tenant_id=tenant_id, provider_id=provider_id)
|
||||
except ToolProviderNotFoundError as exc:
|
||||
raise WorkflowAgentPluginToolsBuildError(
|
||||
"agent_tool_declaration_not_found",
|
||||
f"Dify Plugin Tool provider {provider_id!r} declaration not found: {exc}",
|
||||
) from exc
|
||||
if not tool_names:
|
||||
raise WorkflowAgentPluginToolsBuildError(
|
||||
"agent_tool_declaration_not_found",
|
||||
f"Dify Plugin Tool provider {provider_id!r} declares no tools.",
|
||||
)
|
||||
already_explicit = explicit_by_provider.get(provider_id, set())
|
||||
for tool_name in tool_names:
|
||||
if tool_name in already_explicit:
|
||||
continue
|
||||
expanded.append(tool_config.model_copy(update={"tool_name": tool_name, "runtime_parameters": {}}))
|
||||
return expanded
|
||||
|
||||
def _fetch_tool_runtime(
|
||||
self,
|
||||
*,
|
||||
@ -141,6 +199,8 @@ class WorkflowAgentPluginToolsBuilder:
|
||||
|
||||
@staticmethod
|
||||
def _to_agent_tool_entity(tool_config: AgentSoulDifyToolConfig) -> AgentToolEntity:
|
||||
# Provider-level entries are expanded into per-tool clones before this point.
|
||||
assert tool_config.tool_name is not None
|
||||
return AgentToolEntity(
|
||||
provider_type=ToolProviderType.value_of(tool_config.provider_type),
|
||||
provider_id=WorkflowAgentPluginToolsBuilder._provider_id(tool_config),
|
||||
@ -160,7 +220,9 @@ class WorkflowAgentPluginToolsBuilder:
|
||||
@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.
|
||||
# name aligned with the plugin declaration identity. Provider-level
|
||||
# entries are expanded into per-tool clones before this point.
|
||||
assert tool_config.tool_name is not None
|
||||
return tool_config.tool_name
|
||||
|
||||
def _to_backend_tool_config(
|
||||
@ -190,11 +252,11 @@ class WorkflowAgentPluginToolsBuilder:
|
||||
return DifyPluginToolConfig(
|
||||
plugin_id=plugin_id,
|
||||
provider=provider,
|
||||
tool_name=tool_config.tool_name,
|
||||
tool_name=exposed_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),
|
||||
credentials=self._normalize_credentials(runtime.credentials, tool_name=exposed_name),
|
||||
runtime_parameters=runtime_parameters,
|
||||
parameters=parameters,
|
||||
parameters_json_schema=tool_runtime.get_llm_parameters_json_schema(),
|
||||
|
||||
@ -322,7 +322,11 @@ class WorkflowAgentNodeValidator:
|
||||
for tool in agent_soul.tools.dify_tools:
|
||||
if not tool.enabled:
|
||||
continue
|
||||
exposed_name = tool.tool_name
|
||||
# Provider-level entries (tool_name omitted = all tools of the
|
||||
# provider) are deduped per provider here; the names they expand to
|
||||
# are checked at runtime by the plugin tools builder.
|
||||
provider_key = tool.provider_id or f"{tool.plugin_id}/{tool.provider}"
|
||||
exposed_name = tool.tool_name or f"{provider_key}/*"
|
||||
if exposed_name in exposed_names:
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Workflow Agent node {binding.node_id} has duplicate Dify Plugin Tool name {exposed_name}."
|
||||
|
||||
@ -197,11 +197,15 @@ class AgentComposerValidateResponse(ResponseModel):
|
||||
|
||||
class AgentComposerDifyToolCandidateResponse(ResponseModel):
|
||||
id: str | None = None
|
||||
# "provider" = the whole provider (all of its tools, id "<provider>/*");
|
||||
# "tool" = one tool (id "<provider>/<tool_name>"). See ENG-616.
|
||||
granularity: str | None = None
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
provider: str | None = None
|
||||
provider_id: str | None = None
|
||||
plugin_id: str | None = None
|
||||
tools_count: int | None = None
|
||||
|
||||
|
||||
class AgentComposerSkillCandidateResponse(AgentSkillRefConfig):
|
||||
|
||||
@ -149,6 +149,9 @@ class AgentCliToolEnvConfig(BaseModel):
|
||||
|
||||
|
||||
class AgentCliToolConfig(AgentFlexibleConfig):
|
||||
# Stable mention/reference id (minted by the frontend on creation, backfilled at
|
||||
# composer save) so renaming a CLI tool never breaks `[§cli_tool:<id>§]` mentions.
|
||||
id: str | None = Field(default=None, max_length=255)
|
||||
enabled: bool = True
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
tool_name: str | None = Field(default=None, max_length=255)
|
||||
@ -343,7 +346,11 @@ class AgentSoulDifyToolConfig(BaseModel):
|
||||
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)
|
||||
# ``None`` = provider-level entry selecting ALL tools of the provider (a
|
||||
# provider hosts many tools, like an MCP server). The runtime expands the
|
||||
# entry into every tool the provider currently declares; ``credential_ref``
|
||||
# applies to all of them. Mention form: ``[§tool:<provider>/*§]``.
|
||||
tool_name: str | None = Field(default=None, 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
|
||||
|
||||
@ -11854,6 +11854,7 @@ composer/publish validators and skipped by runtime request builders.
|
||||
| description | string | | No |
|
||||
| enabled | boolean | | No |
|
||||
| env | [AgentCliToolEnvConfig](#agentclitoolenvconfig) | | No |
|
||||
| id | string | | No |
|
||||
| install | string | | No |
|
||||
| install_command | string | | No |
|
||||
| install_commands | [ string ] | | No |
|
||||
@ -11920,11 +11921,13 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| granularity | string | | No |
|
||||
| id | string | | No |
|
||||
| name | string | | No |
|
||||
| plugin_id | string | | No |
|
||||
| provider | string | | No |
|
||||
| provider_id | string | | No |
|
||||
| tools_count | integer | | No |
|
||||
|
||||
#### AgentComposerFileCandidateResponse
|
||||
|
||||
@ -12411,7 +12414,7 @@ new callers should send ``plugin_id`` + ``provider`` when available.
|
||||
| provider_id | string | | No |
|
||||
| provider_type | string | | No |
|
||||
| runtime_parameters | object | | No |
|
||||
| tool_name | string | | Yes |
|
||||
| tool_name | string | | No |
|
||||
|
||||
#### AgentSoulDifyToolCredentialRef
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
@ -43,6 +44,27 @@ _DRAFT_WORKFLOW_VERSION = "draft"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _backfill_cli_tool_ids(agent_soul: AgentSoulConfig | None) -> None:
|
||||
"""Mint stable ids for CLI tools that predate the id field (ENG-616).
|
||||
|
||||
`[§cli_tool:<id>§]` mentions resolve by id so renames never break references;
|
||||
the frontend mints ids for new entries, and save backfills legacy ones. Runs
|
||||
before validation so duplicate-id checks see the final state. Save-only — the
|
||||
validate endpoint must not mutate the payload.
|
||||
"""
|
||||
if agent_soul is None:
|
||||
return
|
||||
seen_ids = {cli_tool.id for cli_tool in agent_soul.tools.cli_tools if cli_tool.id}
|
||||
for cli_tool in agent_soul.tools.cli_tools:
|
||||
if cli_tool.id:
|
||||
continue
|
||||
minted = uuid.uuid4().hex[:12]
|
||||
while minted in seen_ids:
|
||||
minted = uuid.uuid4().hex[:12]
|
||||
cli_tool.id = minted
|
||||
seen_ids.add(minted)
|
||||
|
||||
|
||||
class AgentComposerService:
|
||||
@classmethod
|
||||
def load_workflow_composer(cls, *, tenant_id: str, app_id: str, node_id: str) -> dict[str, Any]:
|
||||
@ -66,6 +88,7 @@ class AgentComposerService:
|
||||
if payload.variant != ComposerVariant.WORKFLOW:
|
||||
raise ValueError("Workflow composer endpoint only accepts workflow variant")
|
||||
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
|
||||
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
|
||||
@ -150,6 +173,7 @@ class AgentComposerService:
|
||||
) -> dict[str, Any]:
|
||||
if payload.variant != ComposerVariant.AGENT_APP:
|
||||
raise ValueError("Agent App composer endpoint only accepts agent_app variant")
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
if payload.agent_soul is None:
|
||||
raise ValueError("agent_soul is required")
|
||||
@ -433,10 +457,28 @@ class AgentComposerService:
|
||||
return []
|
||||
tools: list[dict[str, Any]] = []
|
||||
for provider in providers:
|
||||
for tool in provider.tools or []:
|
||||
provider_tools = provider.tools or []
|
||||
# Provider-level entry first: selecting it means "all tools of this
|
||||
# provider" (a provider hosts many tools, like an MCP server). Its
|
||||
# ``id`` is also the mention id (``[§tool:<provider>/*§]``); the
|
||||
# write-back is one ``tools.dify_tools`` entry with ``tool_name``
|
||||
# omitted.
|
||||
tools.append(
|
||||
{
|
||||
"id": f"{provider.name}/*",
|
||||
"granularity": "provider",
|
||||
"name": provider.label.en_US if provider.label else provider.name,
|
||||
"description": provider.description.en_US if provider.description else None,
|
||||
"provider": provider.name,
|
||||
"plugin_id": provider.plugin_id or None,
|
||||
"tools_count": len(provider_tools),
|
||||
}
|
||||
)
|
||||
for tool in provider_tools:
|
||||
tools.append(
|
||||
{
|
||||
"id": f"{provider.name}/{tool.name}",
|
||||
"granularity": "tool",
|
||||
"name": tool.name,
|
||||
"description": tool.label.en_US if tool.label else tool.name,
|
||||
"provider": provider.name,
|
||||
|
||||
@ -248,6 +248,20 @@ class ComposerConfigValidator:
|
||||
tools = soul.get("tools") or {}
|
||||
cli_tools = tools.get("cli_tools")
|
||||
if isinstance(cli_tools, list):
|
||||
# Mention references resolve `[§cli_tool:<id>§]` by id, so ids must be
|
||||
# unique across the whole list — disabled entries included, since they
|
||||
# stay in config and would make resolution ambiguous.
|
||||
seen_cli_tool_ids: set[str] = set()
|
||||
for entry in cli_tools:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
raw_id = entry.get("id")
|
||||
if isinstance(raw_id, str) and raw_id.strip():
|
||||
if raw_id in seen_cli_tool_ids:
|
||||
raise InvalidComposerConfigError(
|
||||
f"duplicate CLI tool id '{raw_id}': cli_tool mention references require unique ids."
|
||||
)
|
||||
seen_cli_tool_ids.add(raw_id)
|
||||
for entry in cli_tools:
|
||||
if not isinstance(entry, dict) or entry.get("enabled") is False:
|
||||
continue
|
||||
|
||||
@ -57,6 +57,14 @@ _RESIDUAL_MENTION_PATTERN = re.compile(r"\[§([A-Za-z_][A-Za-z0-9_]*:[^§]*?)§\
|
||||
MAX_MENTIONS_PER_PROMPT = 200
|
||||
MAX_MENTION_FIELD_LENGTH = 255
|
||||
|
||||
# Reserved ``tool`` mention id suffix: ``<provider>/*`` means "every tool of this
|
||||
# provider" (a provider hosts many tools, like an MCP server). Single tools use
|
||||
# ``<provider>/<tool_name>``, so ``*`` can never collide with a real tool name.
|
||||
# The mention points at a provider-level config entry (``tool_name`` omitted in
|
||||
# ``tools.dify_tools``); the runtime expands that entry into all of the
|
||||
# provider's tools.
|
||||
ALL_PROVIDER_TOOLS_SUFFIX = "*"
|
||||
|
||||
# Per-surface allowlists (design §2.4): the soul prompt may only reference
|
||||
# soul-owned entities; the workflow job prompt may only reference run-scoped ones.
|
||||
SOUL_PROMPT_ALLOWED_KINDS = frozenset(
|
||||
@ -178,17 +186,33 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
|
||||
return file.name or file.id
|
||||
case MentionKind.TOOL:
|
||||
for tool in agent_soul.tools.dify_tools:
|
||||
aliases = {tool.tool_name} | {
|
||||
f"{prefix}/{tool.tool_name}"
|
||||
for prefix in (tool.provider, tool.provider_id, tool.plugin_id)
|
||||
if prefix
|
||||
}
|
||||
prefixes = {prefix for prefix in (tool.provider, tool.provider_id, tool.plugin_id) if prefix}
|
||||
if tool.plugin_id and tool.provider:
|
||||
prefixes.add(f"{tool.plugin_id}/{tool.provider}")
|
||||
if tool.tool_name is None:
|
||||
# Provider-level entry = all tools of this provider.
|
||||
# ``[§tool:<provider>/*§]`` names the whole provider;
|
||||
# ``[§tool:<provider>/<tool>§]`` names one tool offered
|
||||
# through it.
|
||||
display = tool.provider or tool.provider_id or tool.plugin_id
|
||||
if any(mention.ref_id == f"{prefix}/{ALL_PROVIDER_TOOLS_SUFFIX}" for prefix in prefixes):
|
||||
return f"all {display} tools"
|
||||
# longest prefix first — shorter prefixes can be proper
|
||||
# prefixes of longer ones and would mis-split the ref.
|
||||
for prefix in sorted(prefixes, key=len, reverse=True):
|
||||
single = mention.ref_id.removeprefix(f"{prefix}/")
|
||||
if single != mention.ref_id and single and "/" not in single:
|
||||
return single
|
||||
continue
|
||||
aliases = {tool.tool_name} | {f"{prefix}/{tool.tool_name}" for prefix in prefixes}
|
||||
if mention.ref_id in aliases:
|
||||
return tool.name or tool.tool_name
|
||||
case MentionKind.CLI_TOOL:
|
||||
for cli_tool in agent_soul.tools.cli_tools:
|
||||
if cli_tool.name and mention.ref_id == cli_tool.name:
|
||||
return cli_tool.name
|
||||
# id is the stable reference; name stays as an alias so tokens
|
||||
# minted before ids existed (or hand-written ones) keep working.
|
||||
if mention.ref_id in (cli_tool.id, cli_tool.name):
|
||||
return cli_tool.name or cli_tool.id
|
||||
case MentionKind.KNOWLEDGE:
|
||||
for dataset in agent_soul.knowledge.datasets:
|
||||
if mention.ref_id == dataset.id:
|
||||
@ -247,6 +271,7 @@ def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, str] |
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ALL_PROVIDER_TOOLS_SUFFIX",
|
||||
"MAX_MENTIONS_PER_PROMPT",
|
||||
"MAX_MENTION_FIELD_LENGTH",
|
||||
"MENTION_PATTERN",
|
||||
|
||||
@ -383,3 +383,22 @@ def test_agent_app_composer_routes_are_agent_mode_only() -> None:
|
||||
assert _get_app_model_modes(AgentAppComposerApi.put) == [AppMode.AGENT]
|
||||
assert _get_app_model_modes(AgentAppComposerValidateApi.post) == [AppMode.AGENT]
|
||||
assert _get_app_model_modes(AgentAppComposerCandidatesApi.get) == [AppMode.AGENT]
|
||||
|
||||
|
||||
def test_dify_tool_candidate_response_keeps_granularity_fields():
|
||||
"""Both selection granularities must survive the fields-layer model —
|
||||
the frontend needs granularity/tools_count to render the Tools menu."""
|
||||
from fields.agent_fields import AgentComposerDifyToolCandidateResponse
|
||||
|
||||
provider_entry = AgentComposerDifyToolCandidateResponse.model_validate(
|
||||
{
|
||||
"id": "duckduckgo/*",
|
||||
"granularity": "provider",
|
||||
"name": "DuckDuckGo",
|
||||
"provider": "duckduckgo",
|
||||
"plugin_id": "langgenius/duckduckgo",
|
||||
"tools_count": 2,
|
||||
}
|
||||
).model_dump(exclude_none=True)
|
||||
assert provider_entry["granularity"] == "provider"
|
||||
assert provider_entry["tools_count"] == 2
|
||||
|
||||
@ -437,3 +437,112 @@ def test_legacy_provider_name_and_tool_parameters_normalized():
|
||||
assert tool.runtime_parameters == {"region": "us"}
|
||||
assert tool.credential_ref is not None
|
||||
assert tool.credential_ref.id == "credential-1"
|
||||
|
||||
|
||||
# ── provider-level entries (tool_name omitted = all tools of the provider) ───
|
||||
|
||||
|
||||
def test_provider_level_entry_expands_to_all_tools():
|
||||
runtime_provider = FakeRuntimeProvider(_tool())
|
||||
listed: list[tuple[str, str]] = []
|
||||
|
||||
def lister(*, tenant_id: str, provider_id: str) -> list[str]:
|
||||
listed.append((tenant_id, provider_id))
|
||||
return ["search", "image_search"]
|
||||
|
||||
builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider, provider_tools_lister=lister)
|
||||
tools = AgentSoulToolsConfig.model_validate(
|
||||
{"dify_tools": [{"provider_id": "langgenius/search/search", "credential_type": "unauthorized"}]}
|
||||
)
|
||||
|
||||
result = _build(builder, tools)
|
||||
|
||||
assert result is not None
|
||||
assert [tool.tool_name for tool in result.tools] == ["search", "image_search"]
|
||||
assert listed == [("tenant-1", "langgenius/search/search")]
|
||||
|
||||
|
||||
def test_explicit_tool_entry_wins_over_provider_expansion():
|
||||
builder = WorkflowAgentPluginToolsBuilder(
|
||||
tool_runtime_provider=FakeRuntimeProvider(_tool()),
|
||||
provider_tools_lister=lambda *, tenant_id, provider_id: ["search", "image_search"],
|
||||
)
|
||||
tools = AgentSoulToolsConfig.model_validate(
|
||||
{
|
||||
"dify_tools": [
|
||||
{"provider_id": "langgenius/search/search", "credential_type": "unauthorized"},
|
||||
{
|
||||
"provider_id": "langgenius/search/search",
|
||||
"tool_name": "search",
|
||||
"credential_type": "unauthorized",
|
||||
"runtime_parameters": {"region": "eu"},
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result = _build(builder, tools)
|
||||
|
||||
# the expansion skips "search" (explicit entry wins); no duplicate error
|
||||
assert result is not None
|
||||
assert sorted(tool.tool_name for tool in result.tools) == ["image_search", "search"]
|
||||
|
||||
|
||||
def test_provider_level_entry_with_no_tools_maps_to_declaration_not_found():
|
||||
builder = WorkflowAgentPluginToolsBuilder(
|
||||
tool_runtime_provider=FakeRuntimeProvider(_tool()),
|
||||
provider_tools_lister=lambda *, tenant_id, provider_id: [],
|
||||
)
|
||||
tools = AgentSoulToolsConfig.model_validate(
|
||||
{"dify_tools": [{"provider_id": "langgenius/search/search", "credential_type": "unauthorized"}]}
|
||||
)
|
||||
|
||||
with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info:
|
||||
_build(builder, tools)
|
||||
assert exc_info.value.error_code == "agent_tool_declaration_not_found"
|
||||
|
||||
|
||||
def test_provider_level_entry_unknown_provider_maps_to_declaration_not_found():
|
||||
from core.tools.errors import ToolProviderNotFoundError
|
||||
|
||||
def lister(*, tenant_id: str, provider_id: str) -> list[str]:
|
||||
raise ToolProviderNotFoundError("provider gone")
|
||||
|
||||
builder = WorkflowAgentPluginToolsBuilder(
|
||||
tool_runtime_provider=FakeRuntimeProvider(_tool()), provider_tools_lister=lister
|
||||
)
|
||||
tools = AgentSoulToolsConfig.model_validate(
|
||||
{"dify_tools": [{"provider_id": "langgenius/search/search", "credential_type": "unauthorized"}]}
|
||||
)
|
||||
|
||||
with pytest.raises(WorkflowAgentPluginToolsBuildError) as exc_info:
|
||||
_build(builder, tools)
|
||||
assert exc_info.value.error_code == "agent_tool_declaration_not_found"
|
||||
|
||||
|
||||
def test_list_provider_tool_names_reads_builtin_provider(monkeypatch):
|
||||
"""The default provider-tools lister maps ToolManager's provider controller
|
||||
to the plain name list the expansion step consumes."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from core.workflow.nodes.agent_v2 import plugin_tools_builder as module
|
||||
|
||||
provider = SimpleNamespace(
|
||||
get_tools=lambda: [
|
||||
SimpleNamespace(entity=SimpleNamespace(identity=SimpleNamespace(name="ddg_search"))),
|
||||
SimpleNamespace(entity=SimpleNamespace(identity=SimpleNamespace(name="ddg_news"))),
|
||||
]
|
||||
)
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def fake_get_builtin_provider(provider_id, tenant_id):
|
||||
captured["provider_id"] = provider_id
|
||||
captured["tenant_id"] = tenant_id
|
||||
return provider
|
||||
|
||||
monkeypatch.setattr(module.ToolManager, "get_builtin_provider", staticmethod(fake_get_builtin_provider))
|
||||
|
||||
names = module._list_provider_tool_names(tenant_id="tenant-1", provider_id="langgenius/duckduckgo/duckduckgo")
|
||||
|
||||
assert names == ["ddg_search", "ddg_news"]
|
||||
assert captured == {"provider_id": "langgenius/duckduckgo/duckduckgo", "tenant_id": "tenant-1"}
|
||||
|
||||
@ -191,6 +191,64 @@ def test_publish_validation_rejects_missing_agent_soul_model():
|
||||
)
|
||||
|
||||
|
||||
def test_publish_validation_dedupes_provider_level_tool_entries():
|
||||
"""Provider-level entries (tool_name omitted = all tools of the provider)
|
||||
dedupe per provider; one provider-level + one explicit tool entry for the
|
||||
same provider is fine (the runtime builder reconciles those)."""
|
||||
node_job = WorkflowNodeJobConfig.model_validate({})
|
||||
snapshot = _snapshot()
|
||||
snapshot.config_snapshot = AgentSoulConfig(
|
||||
model=AgentSoulModelConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
tools={
|
||||
"dify_tools": [
|
||||
{"provider_id": "langgenius/duckduckgo/duckduckgo", "credential_type": "unauthorized"},
|
||||
{"provider_id": "langgenius/duckduckgo/duckduckgo", "credential_type": "unauthorized"},
|
||||
]
|
||||
},
|
||||
)
|
||||
session = Mock()
|
||||
session.scalar.side_effect = [_binding(node_job), _agent(), snapshot]
|
||||
|
||||
with pytest.raises(WorkflowAgentNodeValidationError, match="duplicate Dify Plugin Tool"):
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(
|
||||
session=session,
|
||||
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
|
||||
)
|
||||
|
||||
|
||||
def test_publish_validation_accepts_provider_level_plus_explicit_tool_entry():
|
||||
node_job = WorkflowNodeJobConfig.model_validate({})
|
||||
snapshot = _snapshot()
|
||||
snapshot.config_snapshot = AgentSoulConfig(
|
||||
model=AgentSoulModelConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
tools={
|
||||
"dify_tools": [
|
||||
{"provider_id": "langgenius/duckduckgo/duckduckgo", "credential_type": "unauthorized"},
|
||||
{
|
||||
"provider_id": "langgenius/duckduckgo/duckduckgo",
|
||||
"tool_name": "ddg_search",
|
||||
"credential_type": "unauthorized",
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
session = Mock()
|
||||
session.scalar.side_effect = [_binding(node_job), _agent(), snapshot]
|
||||
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(
|
||||
session=session,
|
||||
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
|
||||
)
|
||||
|
||||
|
||||
def test_publish_validation_rejects_duplicate_cli_tool_names():
|
||||
node_job = WorkflowNodeJobConfig.model_validate({})
|
||||
snapshot = _snapshot()
|
||||
|
||||
@ -949,3 +949,43 @@ def test_dataset_rows_filters_malformed_ids(monkeypatch):
|
||||
captured.clear()
|
||||
assert AgentComposerService._dataset_rows(tenant_id="tenant-1", dataset_ids=["nope"]) == {}
|
||||
assert captured == {}
|
||||
|
||||
|
||||
def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatch):
|
||||
"""The slash-menu Tools tab needs both selection granularities: a provider
|
||||
hosts many tools (like an MCP server), so candidates return one
|
||||
provider-level entry (id = <provider>/*, = all tools) plus one per tool."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
provider = SimpleNamespace(
|
||||
name="duckduckgo",
|
||||
plugin_id="langgenius/duckduckgo",
|
||||
label=SimpleNamespace(en_US="DuckDuckGo"),
|
||||
description=SimpleNamespace(en_US="Privacy-first web search"),
|
||||
tools=[
|
||||
SimpleNamespace(name="ddg_search", label=SimpleNamespace(en_US="DuckDuckGo Search")),
|
||||
SimpleNamespace(name="ddg_news", label=SimpleNamespace(en_US="DuckDuckGo News")),
|
||||
],
|
||||
)
|
||||
|
||||
import services.tools.builtin_tools_manage_service as builtin_tools_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
builtin_tools_module.BuiltinToolManageService,
|
||||
"list_builtin_tools",
|
||||
staticmethod(lambda user_id, tenant_id: [provider]),
|
||||
)
|
||||
|
||||
entries = AgentComposerService._workspace_dify_tools(tenant_id="tenant-1", user_id="user-1")
|
||||
|
||||
assert entries[0] == {
|
||||
"id": "duckduckgo/*",
|
||||
"granularity": "provider",
|
||||
"name": "DuckDuckGo",
|
||||
"description": "Privacy-first web search",
|
||||
"provider": "duckduckgo",
|
||||
"plugin_id": "langgenius/duckduckgo",
|
||||
"tools_count": 2,
|
||||
}
|
||||
assert [entry["id"] for entry in entries[1:]] == ["duckduckgo/ddg_search", "duckduckgo/ddg_news"]
|
||||
assert {entry["granularity"] for entry in entries[1:]} == {"tool"}
|
||||
|
||||
@ -123,7 +123,10 @@ def _soul() -> AgentSoulConfig:
|
||||
"files": [{"id": "f-1", "name": "qna_report.pdf"}],
|
||||
},
|
||||
"tools": {
|
||||
"cli_tools": [{"name": "ffmpeg"}, {"name": "disabled-one", "enabled": False}],
|
||||
"cli_tools": [
|
||||
{"id": "ct-1", "name": "ffmpeg"},
|
||||
{"id": "ct-2", "name": "disabled-one", "enabled": False},
|
||||
],
|
||||
},
|
||||
"knowledge": {"datasets": [{"id": "ds-1", "name": "旧名"}, {"id": "ds-gone", "name": "已删"}]},
|
||||
"human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
|
||||
@ -143,6 +146,8 @@ def test_soul_candidates_lists_configured_items_only():
|
||||
assert truncated is False
|
||||
assert [item["kind"] for item in lists["skills_files"]] == ["skill", "file"]
|
||||
assert [item["name"] for item in lists["cli_tools"]] == ["ffmpeg"]
|
||||
# the stable mention id flows through so the frontend can mint [§cli_tool:<id>§]
|
||||
assert [item["id"] for item in lists["cli_tools"]] == ["ct-1"]
|
||||
# enriched from DB; dangling dataset kept with missing flag (placeholder, 0522)
|
||||
knowledge = {item["id"]: item for item in lists["knowledge_datasets"]}
|
||||
assert knowledge["ds-1"]["name"] == "产品手册"
|
||||
|
||||
@ -176,6 +176,81 @@ def test_unresolved_non_knowledge_mentions_warn_target_missing():
|
||||
assert findings["knowledge_retrieval_placeholder"] == []
|
||||
|
||||
|
||||
def test_provider_all_tools_mention_resolves_against_provider_level_entry():
|
||||
# `[§tool:<provider>/*§]` = all tools of that provider; it points at a
|
||||
# provider-level config entry (tool_name omitted), so with the entry present
|
||||
# it must produce neither hard error nor warning…
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "agent_app",
|
||||
"agent_soul": {
|
||||
"prompt": {"system_prompt": "use [§tool:duckduckgo/*:DuckDuckGo 全部§] when needed"},
|
||||
"tools": {
|
||||
"dify_tools": [
|
||||
{
|
||||
"plugin_id": "langgenius/duckduckgo",
|
||||
"provider": "duckduckgo",
|
||||
"credential_type": "unauthorized",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
"save_strategy": "save_to_current_version",
|
||||
}
|
||||
)
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
assert _findings(payload) == {"warnings": [], "knowledge_retrieval_placeholder": []}
|
||||
|
||||
# …and without any entry of that provider it warns like any dangling mention.
|
||||
dangling = _findings(_soul_payload("use [§tool:duckduckgo/*:DuckDuckGo 全部§]"))
|
||||
assert [(w["code"], w["kind"]) for w in dangling["warnings"]] == [("mention_target_missing", "tool")]
|
||||
|
||||
|
||||
def test_duplicate_cli_tool_ids_rejected():
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "agent_app",
|
||||
"agent_soul": {
|
||||
"prompt": {"system_prompt": "plain"},
|
||||
"tools": {
|
||||
"cli_tools": [
|
||||
{"id": "ct-1", "name": "ffmpeg"},
|
||||
# disabled entries still occupy the id namespace
|
||||
{"id": "ct-1", "name": "pandoc", "enabled": False},
|
||||
]
|
||||
},
|
||||
},
|
||||
"save_strategy": "save_to_current_version",
|
||||
}
|
||||
)
|
||||
with pytest.raises(InvalidComposerConfigError, match="duplicate CLI tool id 'ct-1'"):
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
|
||||
|
||||
def test_save_backfills_missing_cli_tool_ids_and_keeps_existing():
|
||||
from services.agent.composer_service import _backfill_cli_tool_ids
|
||||
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "agent_app",
|
||||
"agent_soul": {
|
||||
"prompt": {"system_prompt": "plain"},
|
||||
"tools": {"cli_tools": [{"name": "ffmpeg"}, {"id": "ct-1", "name": "pandoc"}]},
|
||||
},
|
||||
"save_strategy": "save_to_current_version",
|
||||
}
|
||||
)
|
||||
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
|
||||
assert payload.agent_soul is not None
|
||||
minted, existing = payload.agent_soul.tools.cli_tools
|
||||
assert existing.id == "ct-1"
|
||||
assert minted.id is not None
|
||||
assert len(minted.id) == 12
|
||||
assert minted.id != "ct-1"
|
||||
|
||||
|
||||
def test_malformed_marker_warns_but_does_not_block():
|
||||
payload = _soul_payload("hello [§wat:x:y§] world")
|
||||
ComposerConfigValidator.validate_save_payload(payload) # no hard error
|
||||
|
||||
@ -101,7 +101,7 @@ def soul() -> AgentSoulConfig:
|
||||
"credential_type": "unauthorized",
|
||||
},
|
||||
],
|
||||
"cli_tools": [{"name": "ffmpeg"}],
|
||||
"cli_tools": [{"id": "ct-1", "name": "ffmpeg"}],
|
||||
},
|
||||
"knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]},
|
||||
"human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
|
||||
@ -113,7 +113,7 @@ def test_soul_resolver_resolves_each_kind(soul: AgentSoulConfig):
|
||||
resolver = build_soul_mention_resolver(soul)
|
||||
prompt = (
|
||||
"Use [§skill:sk-1§] with [§file:f-1§], search via "
|
||||
"[§tool:tavily/tavily_search:tavily§], run [§cli_tool:ffmpeg§], "
|
||||
"[§tool:tavily/tavily_search:tavily§], run [§cli_tool:ct-1:ffmpeg§], "
|
||||
"ground in [§knowledge:ds-1§], ask [§human:c-1§]."
|
||||
)
|
||||
|
||||
@ -130,6 +130,46 @@ def test_soul_resolver_unknown_ids_degrade(soul: AgentSoulConfig):
|
||||
assert expanded == "旧产品手册"
|
||||
|
||||
|
||||
def test_soul_resolver_cli_tool_resolves_by_id_and_keeps_name_alias(soul: AgentSoulConfig):
|
||||
resolver = build_soul_mention_resolver(soul)
|
||||
# id is the contract; the name alias keeps tokens minted before ids existed working
|
||||
assert expand_prompt_mentions("[§cli_tool:ct-1§]", resolver) == "ffmpeg"
|
||||
assert expand_prompt_mentions("[§cli_tool:ffmpeg§]", resolver) == "ffmpeg"
|
||||
# a rename only changes the expansion, never breaks the id reference
|
||||
soul.tools.cli_tools[0].name = "ffmpeg-v7"
|
||||
assert expand_prompt_mentions("[§cli_tool:ct-1§]", build_soul_mention_resolver(soul)) == "ffmpeg-v7"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def soul_with_provider_entry(soul: AgentSoulConfig) -> AgentSoulConfig:
|
||||
# provider-level entry (tool_name omitted) = all tools of the provider
|
||||
soul.tools.dify_tools.append(
|
||||
soul.tools.dify_tools[0].model_copy(
|
||||
update={"plugin_id": "langgenius/duckduckgo", "provider": "duckduckgo", "tool_name": None}
|
||||
)
|
||||
)
|
||||
return soul
|
||||
|
||||
|
||||
def test_soul_resolver_provider_all_tools_mention(soul_with_provider_entry: AgentSoulConfig):
|
||||
resolver = build_soul_mention_resolver(soul_with_provider_entry)
|
||||
# [§tool:<provider>/*§] = all tools of that provider
|
||||
assert expand_prompt_mentions("Use [§tool:duckduckgo/*:DuckDuckGo 全部§].", resolver) == (
|
||||
"Use all duckduckgo tools."
|
||||
)
|
||||
# plugin-prefixed alias of the same provider
|
||||
assert expand_prompt_mentions("[§tool:langgenius/duckduckgo/duckduckgo/*§]", resolver) == "all duckduckgo tools"
|
||||
# without a provider-level entry the mention dangles -> degrades to label
|
||||
bare = build_soul_mention_resolver(AgentSoulConfig.model_validate({}))
|
||||
assert expand_prompt_mentions("[§tool:duckduckgo/*:DuckDuckGo 全部§]", bare) == "DuckDuckGo 全部"
|
||||
|
||||
|
||||
def test_soul_resolver_single_tool_resolves_via_provider_level_entry(soul_with_provider_entry: AgentSoulConfig):
|
||||
# one tool offered through the provider-level ("all") entry still resolves
|
||||
resolver = build_soul_mention_resolver(soul_with_provider_entry)
|
||||
assert expand_prompt_mentions("[§tool:duckduckgo/ddg_search§]", resolver) == "ddg_search"
|
||||
|
||||
|
||||
# ── node-job resolver ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@ -385,6 +385,7 @@ export type AgentCliToolConfig = {
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
env?: AgentCliToolEnvConfig
|
||||
id?: string | null
|
||||
install?: string | null
|
||||
install_command?: string | null
|
||||
install_commands?: Array<string>
|
||||
@ -416,7 +417,7 @@ export type AgentSoulDifyToolConfig = {
|
||||
runtime_parameters?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
tool_name: string
|
||||
tool_name?: string | null
|
||||
}
|
||||
|
||||
export type AgentModerationProviderConfig = {
|
||||
|
||||
@ -500,6 +500,7 @@ export const zAgentCliToolConfig = z.object({
|
||||
description: z.string().nullish(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
env: zAgentCliToolEnvConfig.optional(),
|
||||
id: z.string().max(255).nullish(),
|
||||
install: z.string().nullish(),
|
||||
install_command: z.string().nullish(),
|
||||
install_commands: z.array(z.string()).optional(),
|
||||
@ -551,7 +552,7 @@ export const zAgentSoulDifyToolConfig = z.object({
|
||||
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),
|
||||
tool_name: z.string().min(1).max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@ -1722,6 +1722,7 @@ export type AgentCliToolConfig = {
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
env?: AgentCliToolEnvConfig
|
||||
id?: string | null
|
||||
install?: string | null
|
||||
install_command?: string | null
|
||||
install_commands?: Array<string>
|
||||
@ -1742,11 +1743,13 @@ export type AgentCliToolConfig = {
|
||||
|
||||
export type AgentComposerDifyToolCandidateResponse = {
|
||||
description?: string | null
|
||||
granularity?: string | null
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
plugin_id?: string | null
|
||||
provider?: string | null
|
||||
provider_id?: string | null
|
||||
tools_count?: number | null
|
||||
}
|
||||
|
||||
export type AgentKnowledgeDatasetConfig = {
|
||||
@ -2003,7 +2006,7 @@ export type AgentSoulDifyToolConfig = {
|
||||
runtime_parameters?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
tool_name: string
|
||||
tool_name?: string | null
|
||||
}
|
||||
|
||||
export type AgentCliToolAuthorizationStatus
|
||||
|
||||
@ -1775,11 +1775,13 @@ export const zAgentComposerNodeJobCandidatesResponse = z.object({
|
||||
*/
|
||||
export const zAgentComposerDifyToolCandidateResponse = z.object({
|
||||
description: z.string().nullish(),
|
||||
granularity: z.string().nullish(),
|
||||
id: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
plugin_id: z.string().nullish(),
|
||||
provider: z.string().nullish(),
|
||||
provider_id: z.string().nullish(),
|
||||
tools_count: z.int().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
@ -2314,6 +2316,7 @@ export const zAgentCliToolConfig = z.object({
|
||||
description: z.string().nullish(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
env: zAgentCliToolEnvConfig.optional(),
|
||||
id: z.string().max(255).nullish(),
|
||||
install: z.string().nullish(),
|
||||
install_command: z.string().nullish(),
|
||||
install_commands: z.array(z.string()).optional(),
|
||||
@ -2587,7 +2590,7 @@ export const zAgentSoulDifyToolConfig = z.object({
|
||||
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),
|
||||
tool_name: z.string().min(1).max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user