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:
zyssyz123 2026-06-12 10:06:35 +08:00 committed by GitHub
parent aff8f82bc0
commit 6cfd96ccd6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 538 additions and 23 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

@ -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}."

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"] == "产品手册"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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