From 6cfd96ccd6d5f0378a375b65b368b3c4877f809a Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Fri, 12 Jun 2026 10:06:35 +0800 Subject: [PATCH] feat: agent slash menu backend (#37331) Co-authored-by: Claude Fable 5 Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../nodes/agent_v2/plugin_tools_builder.py | 72 +++++++++++- .../workflow/nodes/agent_v2/validators.py | 6 +- api/fields/agent_fields.py | 4 + api/models/agent_config_entities.py | 9 +- api/openapi/markdown/console-swagger.md | 5 +- api/services/agent/composer_service.py | 44 ++++++- api/services/agent/composer_validator.py | 14 +++ api/services/agent/prompt_mentions.py | 39 +++++-- .../console/agent/test_agent_controllers.py | 19 +++ .../agent_v2/test_plugin_tools_builder.py | 109 ++++++++++++++++++ .../nodes/agent_v2/test_validators.py | 58 ++++++++++ .../services/agent/test_agent_services.py | 40 +++++++ .../agent/test_composer_candidates.py | 7 +- .../agent/test_composer_mention_validation.py | 75 ++++++++++++ .../services/agent/test_prompt_mentions.py | 44 ++++++- .../generated/api/console/agents/types.gen.ts | 3 +- .../generated/api/console/agents/zod.gen.ts | 3 +- .../generated/api/console/apps/types.gen.ts | 5 +- .../generated/api/console/apps/zod.gen.ts | 5 +- 19 files changed, 538 insertions(+), 23 deletions(-) diff --git a/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py b/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py index 09defaf2d51..ccf8f9fa171 100644 --- a/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py +++ b/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py @@ -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(), diff --git a/api/core/workflow/nodes/agent_v2/validators.py b/api/core/workflow/nodes/agent_v2/validators.py index be0e6549add..59e6934ba5e 100644 --- a/api/core/workflow/nodes/agent_v2/validators.py +++ b/api/core/workflow/nodes/agent_v2/validators.py @@ -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}." diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py index d27a5708bf6..4c9b48094ae 100644 --- a/api/fields/agent_fields.py +++ b/api/fields/agent_fields.py @@ -197,11 +197,15 @@ class AgentComposerValidateResponse(ResponseModel): class AgentComposerDifyToolCandidateResponse(ResponseModel): id: str | None = None + # "provider" = the whole provider (all of its tools, id "/*"); + # "tool" = one tool (id "/"). 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): diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index abc67123132..7d80ce30fb4 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -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:§]` 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:/*§]``. + 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 diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index 2f30adccb12..56b6b78093b 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -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 diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 628c93ec389..9458460f512 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -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:§]` 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:/*§]``); 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, diff --git a/api/services/agent/composer_validator.py b/api/services/agent/composer_validator.py index f7bd70ac267..47c255aae2d 100644 --- a/api/services/agent/composer_validator.py +++ b/api/services/agent/composer_validator.py @@ -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:§]` 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 diff --git a/api/services/agent/prompt_mentions.py b/api/services/agent/prompt_mentions.py index 880e14af9b3..921d6838b26 100644 --- a/api/services/agent/prompt_mentions.py +++ b/api/services/agent/prompt_mentions.py @@ -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: ``/*`` means "every tool of this +# provider" (a provider hosts many tools, like an MCP server). Single tools use +# ``/``, 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:/*§]`` names the whole 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", diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index c1f3f523956..e4eca728200 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -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 diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py index c27b560e457..1a2e09fd817 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py @@ -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"} diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py index 61876c8ac1e..7237f01319b 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py @@ -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() diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index c1e868b62d5..fd73d65d32d 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -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 = /*, = 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"} diff --git a/api/tests/unit_tests/services/agent/test_composer_candidates.py b/api/tests/unit_tests/services/agent/test_composer_candidates.py index d1ac51bd776..5eb9acf8b81 100644 --- a/api/tests/unit_tests/services/agent/test_composer_candidates.py +++ b/api/tests/unit_tests/services/agent/test_composer_candidates.py @@ -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:§] + 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"] == "产品手册" diff --git a/api/tests/unit_tests/services/agent/test_composer_mention_validation.py b/api/tests/unit_tests/services/agent/test_composer_mention_validation.py index 93efbb4ebe9..e332b48ab1e 100644 --- a/api/tests/unit_tests/services/agent/test_composer_mention_validation.py +++ b/api/tests/unit_tests/services/agent/test_composer_mention_validation.py @@ -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:/*§]` = 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 diff --git a/api/tests/unit_tests/services/agent/test_prompt_mentions.py b/api/tests/unit_tests/services/agent/test_prompt_mentions.py index e615a6e7c57..b85a0a752e1 100644 --- a/api/tests/unit_tests/services/agent/test_prompt_mentions.py +++ b/api/tests/unit_tests/services/agent/test_prompt_mentions.py @@ -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:/*§] = 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 ───────────────────────────────────────────────────────── diff --git a/packages/contracts/generated/api/console/agents/types.gen.ts b/packages/contracts/generated/api/console/agents/types.gen.ts index 906e98a795d..9b3eab813d8 100644 --- a/packages/contracts/generated/api/console/agents/types.gen.ts +++ b/packages/contracts/generated/api/console/agents/types.gen.ts @@ -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 @@ -416,7 +417,7 @@ export type AgentSoulDifyToolConfig = { runtime_parameters?: { [key: string]: unknown } - tool_name: string + tool_name?: string | null } export type AgentModerationProviderConfig = { diff --git a/packages/contracts/generated/api/console/agents/zod.gen.ts b/packages/contracts/generated/api/console/agents/zod.gen.ts index af804e9d544..eeb6c01dc40 100644 --- a/packages/contracts/generated/api/console/agents/zod.gen.ts +++ b/packages/contracts/generated/api/console/agents/zod.gen.ts @@ -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(), }) /** diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 8fbc81b13fe..7de8e67d776 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -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 @@ -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 diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 96baed59795..67136f0b2ff 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -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(), }) /**