mirror of
https://github.com/langgenius/dify.git
synced 2026-06-12 11:17:59 +08:00
feat(api): cli_tool mentions use stable ids + reserved [§tool:*§] all-tools 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 id "*" as the all-tools reference: expands to a fixed readable phrase, always resolves (never dangles), no config write-back. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -57,6 +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"
|
||||
|
||||
# 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 +184,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 == 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}"
|
||||
@ -187,8 +196,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:
|
||||
@ -247,6 +258,7 @@ def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, str] |
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ALL_TOOLS_MENTION_ID",
|
||||
"MAX_MENTIONS_PER_PROMPT",
|
||||
"MAX_MENTION_FIELD_LENGTH",
|
||||
"MENTION_PATTERN",
|
||||
|
||||
@ -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,59 @@ 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")
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
assert _findings(payload) == {"warnings": [], "knowledge_retrieval_placeholder": []}
|
||||
|
||||
|
||||
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,26 @@ 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_all_tools_sentinel_never_dangles(soul: AgentSoulConfig):
|
||||
assert (
|
||||
expand_prompt_mentions("Use [§tool:*:ALL TOOLS§].", build_soul_mention_resolver(soul))
|
||||
== "Use all available tools."
|
||||
)
|
||||
# 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"
|
||||
|
||||
|
||||
# ── node-job resolver ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user