feat(api): [§tool:<provider>/*§] selects all tools of one provider — ENG-616

Product clarification: "all tools" must be per-provider ("all DuckDuckGo
tools", like adding a whole MCP server), not agent-global. Replaces the
global [§tool:*§] sentinel from 0d872cfaad:

- mention: [§tool:<provider>/*§] = every tool of that provider;
  [§tool:<provider>/<tool_name>§] = one tool (unchanged). The /* form
  resolves against a provider-level config entry and expands to
  "all <provider> tools"; single-tool mentions also resolve through a
  provider-level entry (the tool is offered via "all"). Longest-prefix
  matching guards plugin_id/provider alias overlaps.
- config: AgentSoulDifyToolConfig.tool_name optional again — omitted =
  provider-level entry (credential_ref applies to all tools).
- candidates dify_tools: two granularities per provider — one
  {granularity:"provider", id:"<provider>/*", tools_count} entry plus one
  {granularity:"tool", id:"<provider>/<tool_name>"} per tool; ids double
  as mention ids.
- runtime: provider-level entries expand to every tool the provider
  declares (injectable ProviderToolsLister, default via ToolManager);
  explicit per-tool entries of the same provider win over the expansion;
  unknown/empty providers map to agent_tool_declaration_not_found.
- dangling [§tool:<provider>/*§] without a provider-level entry warns
  mention_target_missing like any other mention (no special-casing).

cli_tool stable-id behavior from 0d872cfaad is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yansong Zhang
2026-06-11 17:39:56 +08:00
parent 0d872cfaad
commit 5af761690b
8 changed files with 292 additions and 32 deletions

View File

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

View File

@ -340,7 +340,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

View File

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

View File

@ -57,12 +57,13 @@ _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 meaning "every tool this agent has" (single tools use
# ``<provider>/<tool_name>``, so ``*`` can never collide with a real tool id). The
# menu row is a static frontend entry; selecting it writes nothing back to config,
# and validation always resolves it, so it can never dangle.
ALL_TOOLS_MENTION_ID = "*"
_ALL_TOOLS_MENTION_TEXT = "all available tools"
# 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.
@ -184,14 +185,26 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
if mention.ref_id in (file.id, file.name):
return file.name or file.id
case MentionKind.TOOL:
if mention.ref_id == ALL_TOOLS_MENTION_ID:
return _ALL_TOOLS_MENTION_TEXT
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:
@ -258,7 +271,7 @@ def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, str] |
__all__ = [
"ALL_TOOLS_MENTION_ID",
"ALL_PROVIDER_TOOLS_SUFFIX",
"MAX_MENTIONS_PER_PROMPT",
"MAX_MENTION_FIELD_LENGTH",
"MENTION_PATTERN",

View File

@ -437,3 +437,84 @@ 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"

View File

@ -918,3 +918,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"}

View File

@ -176,13 +176,35 @@ def test_unresolved_non_knowledge_mentions_warn_target_missing():
assert findings["knowledge_retrieval_placeholder"] == []
def test_all_tools_mention_never_warns_target_missing():
# `[§tool:*§]` is the reserved all-tools reference — always valid, even with
# zero configured tools, so it must produce neither hard error nor warning.
payload = _soul_payload("use [§tool:*:ALL TOOLS§] when needed")
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(

View File

@ -140,14 +140,34 @@ def test_soul_resolver_cli_tool_resolves_by_id_and_keeps_name_alias(soul: AgentS
assert expand_prompt_mentions("[§cli_tool:ct-1§]", build_soul_mention_resolver(soul)) == "ffmpeg-v7"
def test_soul_resolver_all_tools_sentinel_never_dangles(soul: AgentSoulConfig):
assert (
expand_prompt_mentions("Use [§tool:*:ALL TOOLS§].", build_soul_mention_resolver(soul))
== "Use all available tools."
@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}
)
)
# resolves even with zero configured tools — the sentinel is always valid
empty_resolver = build_soul_mention_resolver(AgentSoulConfig.model_validate({}))
assert expand_prompt_mentions("[§tool:*§]", empty_resolver) == "all available tools"
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 ─────────────────────────────────────────────────────────