Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dba054ce7e |
@@ -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,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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user