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:
Yansong Zhang
2026-06-11 15:06:40 +08:00
parent 04f4ae2c4b
commit 0d872cfaad
7 changed files with 136 additions and 5 deletions

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)

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")

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

View File

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

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

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