Compare commits

...
Author SHA1 Message Date
Yansong ZhangandClaude Fable 5 dba054ce7e feat(api): cli_tool mentions use stable ids + [§tool:<provider>/*§] provider-wide reference — ENG-616
Review feedback (weiqiang, Slash-menu 接入参考):
- AgentCliToolConfig gains a stable id; [§cli_tool:<id>§] resolves by id
  (name kept as alias for pre-id tokens), composer save backfills missing
  ids, duplicate ids rejected at validate/save.
- tool kind reserves the "/*" id suffix: a provider groups many concrete
  tools (like an MCP server), so [§tool:<provider>/*§] references the whole
  group while <provider>/<tool_name> stays the single-tool form. The prefix
  accepts the same provider/provider_id/plugin_id aliases; it resolves
  against configured tools.dify_tools (unconfigured provider dangles like
  any other mention).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:32:53 +08:00
7 changed files with 180 additions and 5 deletions
+3
View File
@@ -118,6 +118,9 @@ class AgentPermissionConfig(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)
+24
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")
+14
View File
@@ -271,6 +271,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
+29 -2
View File
@@ -57,6 +57,15 @@ _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 meaning "every configured tool under one
# provider" — a Dify tool provider groups many concrete tools, like an MCP server.
# ``<provider>/*`` references the whole group; ``<provider>/<tool_name>`` stays the
# single-tool form. The prefix accepts the same provider/provider_id/plugin_id
# aliases as single-tool ids, and ``*`` is never a valid tool name, so the wildcard
# cannot collide with a real tool id. It resolves against the configured
# ``tools.dify_tools`` like any other mention (unconfigured provider → dangling).
TOOL_PROVIDER_WILDCARD_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(
@@ -177,6 +186,8 @@ 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.endswith(TOOL_PROVIDER_WILDCARD_SUFFIX):
return _resolve_tool_provider_wildcard(agent_soul, mention.ref_id)
for tool in agent_soul.tools.dify_tools:
aliases = {tool.tool_name} | {
f"{prefix}/{tool.tool_name}"
@@ -187,8 +198,10 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
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:
@@ -202,6 +215,19 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
return _resolve
def _resolve_tool_provider_wildcard(agent_soul: AgentSoulConfig, ref_id: str) -> str | None:
"""Expand ``<provider>/*`` to a readable phrase when that provider has at least
one configured tool; unconfigured providers dangle like any other mention."""
prefix = ref_id[: -len(TOOL_PROVIDER_WILDCARD_SUFFIX)]
if not prefix:
return None
for tool in agent_soul.tools.dify_tools:
if prefix in (tool.provider, tool.provider_id, tool.plugin_id):
display = tool.provider or prefix.rsplit("/", 1)[-1]
return f"all {display} tools"
return None
def build_node_job_mention_resolver(node_job: WorkflowNodeJobConfig) -> MentionResolver:
"""Resolve job-surface mentions. ``node_output`` expands to the stored
reference name only — values stay in the Workflow context block (design §4.2)."""
@@ -252,6 +278,7 @@ __all__ = [
"MENTION_PATTERN",
"NODE_JOB_PROMPT_ALLOWED_KINDS",
"SOUL_PROMPT_ALLOWED_KINDS",
"TOOL_PROVIDER_WILDCARD_SUFFIX",
"MentionKind",
"MentionResolver",
"PromptMention",
@@ -123,7 +123,10 @@ def _soul() -> AgentSoulConfig:
"files": [{"id": "f-1", "name": "qna_report.pdf"}],
},
"tools": {
"cli_tools": [{"name": "ffmpeg"}, {"name": "disabled-one", "enabled": False}],
"cli_tools": [
{"id": "ct-1", "name": "ffmpeg"},
{"id": "ct-2", "name": "disabled-one", "enabled": False},
],
},
"knowledge": {"datasets": [{"id": "ds-1", "name": "旧名"}, {"id": "ds-gone", "name": "已删"}]},
"human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
@@ -143,6 +146,8 @@ def test_soul_candidates_lists_configured_items_only():
assert truncated is False
assert [item["kind"] for item in lists["skills_files"]] == ["skill", "file"]
assert [item["name"] for item in lists["cli_tools"]] == ["ffmpeg"]
# the stable mention id flows through so the frontend can mint [§cli_tool:<id>§]
assert [item["id"] for item in lists["cli_tools"]] == ["ct-1"]
# enriched from DB; dangling dataset kept with missing flag (placeholder, 0522)
knowledge = {item["id"]: item for item in lists["knowledge_datasets"]}
assert knowledge["ds-1"]["name"] == "产品手册"
@@ -176,6 +176,83 @@ def test_unresolved_non_knowledge_mentions_warn_target_missing():
assert findings["knowledge_retrieval_placeholder"] == []
def test_provider_wildcard_tool_mention_resolves_against_configured_provider():
# `[§tool:<provider>/*§]` references every tool of one provider (MCP-server
# style); it validates against config like any other mention.
payload = ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"agent_soul": {
"prompt": {"system_prompt": "search with [§tool:tavily/*:ALL TAVILY TOOLS§]"},
"tools": {
"dify_tools": [
{
"plugin_id": "langgenius/tavily",
"provider": "tavily",
"tool_name": "tavily_search",
"credential_type": "unauthorized",
}
]
},
},
"save_strategy": "save_to_current_version",
}
)
ComposerConfigValidator.validate_save_payload(payload)
assert _findings(payload) == {"warnings": [], "knowledge_retrieval_placeholder": []}
def test_provider_wildcard_tool_mention_warns_when_provider_not_configured():
findings = _findings(_soul_payload("use [§tool:github/*:GITHUB TOOLS§]"))
codes = [(w["code"], w["kind"]) for w in findings["warnings"]]
assert ("mention_target_missing", "tool") in codes
def test_duplicate_cli_tool_ids_rejected():
payload = ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"agent_soul": {
"prompt": {"system_prompt": "plain"},
"tools": {
"cli_tools": [
{"id": "ct-1", "name": "ffmpeg"},
# disabled entries still occupy the id namespace
{"id": "ct-1", "name": "pandoc", "enabled": False},
]
},
},
"save_strategy": "save_to_current_version",
}
)
with pytest.raises(InvalidComposerConfigError, match="duplicate CLI tool id 'ct-1'"):
ComposerConfigValidator.validate_save_payload(payload)
def test_save_backfills_missing_cli_tool_ids_and_keeps_existing():
from services.agent.composer_service import _backfill_cli_tool_ids
payload = ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"agent_soul": {
"prompt": {"system_prompt": "plain"},
"tools": {"cli_tools": [{"name": "ffmpeg"}, {"id": "ct-1", "name": "pandoc"}]},
},
"save_strategy": "save_to_current_version",
}
)
_backfill_cli_tool_ids(payload.agent_soul)
assert payload.agent_soul is not None
minted, existing = payload.agent_soul.tools.cli_tools
assert existing.id == "ct-1"
assert minted.id is not None
assert len(minted.id) == 12
assert minted.id != "ct-1"
def test_malformed_marker_warns_but_does_not_block():
payload = _soul_payload("hello [§wat:x:y§] world")
ComposerConfigValidator.validate_save_payload(payload) # no hard error
@@ -101,7 +101,7 @@ def soul() -> AgentSoulConfig:
"credential_type": "unauthorized",
},
],
"cli_tools": [{"name": "ffmpeg"}],
"cli_tools": [{"id": "ct-1", "name": "ffmpeg"}],
},
"knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]},
"human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
@@ -113,7 +113,7 @@ def test_soul_resolver_resolves_each_kind(soul: AgentSoulConfig):
resolver = build_soul_mention_resolver(soul)
prompt = (
"Use [§skill:sk-1§] with [§file:f-1§], search via "
"[§tool:tavily/tavily_search:tavily§], run [§cli_tool:ffmpeg§], "
"[§tool:tavily/tavily_search:tavily§], run [§cli_tool:ct-1:ffmpeg§], "
"ground in [§knowledge:ds-1§], ask [§human:c-1§]."
)
@@ -130,6 +130,31 @@ 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"
def test_soul_resolver_provider_wildcard_references_all_tools_of_one_provider(soul: AgentSoulConfig):
# a provider groups many concrete tools (like an MCP server); `<provider>/*`
# references the whole group while `<provider>/<tool_name>` stays single-tool
resolver = build_soul_mention_resolver(soul)
assert expand_prompt_mentions("Use [§tool:tavily/*:ALL TAVILY TOOLS§].", resolver) == "Use all tavily tools."
# the wildcard prefix accepts the same plugin_id/provider_id aliases as single-tool ids
assert expand_prompt_mentions("[§tool:langgenius/tavily/*§]", resolver) == "all tavily tools"
def test_soul_resolver_provider_wildcard_dangles_when_provider_not_configured(soul: AgentSoulConfig):
# unconfigured provider degrades to label, exactly like a dangling single-tool mention
resolver = build_soul_mention_resolver(soul)
assert expand_prompt_mentions("[§tool:github/*:GITHUB TOOLS§]", resolver) == "GITHUB TOOLS"
# ── node-job resolver ─────────────────────────────────────────────────────────