feat: agent slash menu backend (#37268)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123 2026-06-10 18:40:03 +08:00 committed by GitHub
parent 6658a7c5e7
commit 2c5c8e82c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1972 additions and 121 deletions

View File

@ -90,10 +90,12 @@ class WorkflowAgentComposerValidateApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, app_model: App, node_id: str):
@with_current_tenant_id
def post(self, tenant_id: str, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/candidates")
@ -105,10 +107,17 @@ class WorkflowAgentComposerCandidatesApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, app_model: App, node_id: str):
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, current_user_id: str, app_model: App, node_id: str):
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_workflow_candidates(app_id=app_model.id),
AgentComposerService.get_workflow_candidates(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
user_id=current_user_id,
),
)
@ -167,7 +176,7 @@ class AgentAppComposerApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model()
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App):
return dump_response(
@ -181,7 +190,7 @@ class AgentAppComposerApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@get_app_model()
@get_app_model(mode=[AppMode.AGENT])
@with_current_user_id
@with_current_tenant_id
def put(self, tenant_id: str, account_id: str, app_model: App):
@ -206,11 +215,13 @@ class AgentAppComposerValidateApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def post(self, app_model: App):
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def post(self, tenant_id: str, app_model: App):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@console_ns.route("/apps/<uuid:app_id>/agent-composer/candidates")
@ -221,9 +232,15 @@ class AgentAppComposerCandidatesApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model: App):
@get_app_model(mode=[AppMode.AGENT])
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, current_user_id: str, app_model: App):
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_agent_app_candidates(app_id=app_model.id),
AgentComposerService.get_agent_app_candidates(
tenant_id=tenant_id,
app_id=app_model.id,
user_id=current_user_id,
),
)

View File

@ -35,6 +35,7 @@ from core.workflow.nodes.agent_v2.plugin_tools_builder import (
from core.workflow.nodes.agent_v2.runtime_request_builder import build_shell_layer_config
from models.agent_config_entities import AgentSoulConfig
from models.provider_ids import ModelProviderID
from services.agent.prompt_mentions import build_soul_mention_resolver, expand_prompt_mentions
class AgentAppRuntimeRequestBuildError(ValueError):
@ -135,7 +136,12 @@ class AgentAppRuntimeRequestBuilder:
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
agent_mode="agent_app",
),
agent_soul_prompt=agent_soul.prompt.system_prompt or None,
# ENG-616: expand slash-menu mention tokens to canonical names so
# no frontend-internal {{#…#}} marker ever reaches the model.
agent_soul_prompt=expand_prompt_mentions(
agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
).strip()
or None,
user_prompt=context.user_query,
tools=tools_layer,
include_shell=dify_config.AGENT_SHELL_ENABLED,

View File

@ -0,0 +1,86 @@
"""Draft-workflow graph topology helper, shared by Agent v2 publish validation
and the agent-composer candidates endpoint (ENG-615).
Extracted from ``core/workflow/nodes/agent_v2/validators.py`` so both call sites
parse the same ``Workflow.graph`` JSON shape (``nodes`` with string ids,
``edges`` with ``source``/``target``).
"""
from __future__ import annotations
from collections import defaultdict, deque
from collections.abc import Mapping, Sequence
from typing import Any
class WorkflowGraphTopology:
def __init__(self, *, node_ids: set[str], incoming: Mapping[str, Sequence[str]]) -> None:
self._node_ids = node_ids
self._incoming = incoming
@classmethod
def from_graph(cls, graph: Mapping[str, Any]) -> WorkflowGraphTopology:
node_ids = cls._node_ids_from_graph(graph)
incoming: dict[str, list[str]] = defaultdict(list)
edges = graph.get("edges")
if isinstance(edges, list):
for edge in edges:
if not isinstance(edge, Mapping):
continue
source = edge.get("source")
target = edge.get("target")
if isinstance(source, str) and isinstance(target, str):
incoming[target].append(source)
return cls(node_ids=node_ids, incoming=incoming)
def has_node(self, node_id: str) -> bool:
return node_id in self._node_ids
def is_upstream(self, *, source_node_id: str, target_node_id: str) -> bool:
if source_node_id == target_node_id:
return False
visited: set[str] = set()
queue: deque[str] = deque(self._incoming.get(target_node_id, ()))
while queue:
candidate = queue.popleft()
if candidate == source_node_id:
return True
if candidate in visited:
continue
visited.add(candidate)
queue.extend(self._incoming.get(candidate, ()))
return False
def upstream_node_ids(self, target_node_id: str) -> set[str]:
"""All graph nodes reachable upstream of ``target_node_id`` (excluding it).
Edges may reference ids missing from ``nodes`` (half-deleted graphs);
only real nodes are returned.
"""
visited: set[str] = set()
queue: deque[str] = deque(self._incoming.get(target_node_id, ()))
while queue:
candidate = queue.popleft()
if candidate in visited:
continue
visited.add(candidate)
queue.extend(self._incoming.get(candidate, ()))
visited.discard(target_node_id)
return visited & self._node_ids
@staticmethod
def _node_ids_from_graph(graph: Mapping[str, Any]) -> set[str]:
node_ids: set[str] = set()
nodes = graph.get("nodes")
if not isinstance(nodes, list):
return node_ids
for node in nodes:
if not isinstance(node, Mapping):
continue
node_id = node.get("id")
if isinstance(node_id, str):
node_ids.add(node_id)
return node_ids
__all__ = ["WorkflowGraphTopology"]

View File

@ -45,6 +45,11 @@ from models.agent_config_entities import (
effective_declared_outputs as _effective_declared_outputs,
)
from models.provider_ids import ModelProviderID
from services.agent.prompt_mentions import (
build_node_job_mention_resolver,
build_soul_mention_resolver,
expand_prompt_mentions,
)
from .output_failure_orchestrator import retry_idempotency_key
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
@ -129,7 +134,16 @@ class WorkflowAgentRuntimeRequestBuilder:
metadata = self._build_metadata(context, agent_soul, node_job)
workflow_context_prompt = self._build_workflow_context_prompt(context, node_job)
workflow_job_prompt = node_job.workflow_prompt.strip() or "Run this workflow Agent Node for the current run."
# ENG-616: expand slash-menu mention tokens into model-readable names.
# node_output mentions expand to their reference name only — the value
# stays in the Workflow context block (user_prompt) below.
workflow_job_prompt = (
expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip()
or "Run this workflow Agent Node for the current run."
)
soul_prompt = expand_prompt_mentions(
agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
).strip()
user_prompt = workflow_context_prompt.strip() or "Use the current workflow context."
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
try:
@ -187,7 +201,7 @@ class WorkflowAgentRuntimeRequestBuilder:
agent_mode=self._agent_backend_agent_mode(context.dify_context.invoke_from),
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
),
agent_soul_prompt=agent_soul.prompt.system_prompt or None,
agent_soul_prompt=soul_prompt or None,
workflow_node_job_prompt=workflow_job_prompt,
user_prompt=user_prompt,
output=self._build_output_config(node_job.declared_outputs),

View File

@ -1,12 +1,12 @@
from __future__ import annotations
from collections import defaultdict, deque
from collections.abc import Iterator, Mapping, Sequence
from collections.abc import Iterator, Mapping
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.workflow.graph_topology import WorkflowGraphTopology
from graphon.enums import BuiltinNodeTypes
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding
from models.agent_config_entities import (
@ -523,54 +523,6 @@ class WorkflowAgentNodeValidator:
)
class _WorkflowGraphTopology:
def __init__(self, *, node_ids: set[str], incoming: Mapping[str, Sequence[str]]) -> None:
self._node_ids = node_ids
self._incoming = incoming
@classmethod
def from_graph(cls, graph: Mapping[str, Any]) -> _WorkflowGraphTopology:
node_ids = cls._node_ids_from_graph(graph)
incoming: dict[str, list[str]] = defaultdict(list)
edges = graph.get("edges")
if isinstance(edges, list):
for edge in edges:
if not isinstance(edge, Mapping):
continue
source = edge.get("source")
target = edge.get("target")
if isinstance(source, str) and isinstance(target, str):
incoming[target].append(source)
return cls(node_ids=node_ids, incoming=incoming)
def has_node(self, node_id: str) -> bool:
return node_id in self._node_ids
def is_upstream(self, *, source_node_id: str, target_node_id: str) -> bool:
if source_node_id == target_node_id:
return False
visited: set[str] = set()
queue: deque[str] = deque(self._incoming.get(target_node_id, ()))
while queue:
candidate = queue.popleft()
if candidate == source_node_id:
return True
if candidate in visited:
continue
visited.add(candidate)
queue.extend(self._incoming.get(candidate, ()))
return False
@staticmethod
def _node_ids_from_graph(graph: Mapping[str, Any]) -> set[str]:
node_ids: set[str] = set()
nodes = graph.get("nodes")
if not isinstance(nodes, list):
return node_ids
for node in nodes:
if not isinstance(node, Mapping):
continue
node_id = node.get("id")
if isinstance(node_id, str):
node_ids.add(node_id)
return node_ids
# Extracted to core/workflow/graph_topology.py (shared with the agent-composer
# candidates endpoint, ENG-615); kept as a private alias for existing call sites.
_WorkflowGraphTopology = WorkflowGraphTopology

View File

@ -1,4 +1,4 @@
from typing import Literal
from typing import Annotated, Literal
from pydantic import Field
@ -14,6 +14,7 @@ from models.agent import (
)
from models.agent_config_entities import (
AgentCliToolConfig,
AgentFileRefConfig,
AgentHumanContactConfig,
AgentKnowledgeDatasetConfig,
AgentSkillRefConfig,
@ -154,6 +155,7 @@ class WorkflowAgentComposerResponse(ResponseModel):
effective_declared_outputs: list[DeclaredOutputConfig] = Field(default_factory=list)
save_options: list[ComposerSaveStrategy]
impact_summary: AgentComposerImpactResponse | None = None
validation: "ComposerValidationFindingsResponse | None" = None
app_id: str | None = None
workflow_id: str | None = None
node_id: str | None = None
@ -165,11 +167,32 @@ class AgentAppComposerResponse(ResponseModel):
active_config_snapshot: AgentConfigSnapshotSummaryResponse
agent_soul: AgentSoulConfig
save_options: list[ComposerSaveStrategy]
validation: "ComposerValidationFindingsResponse | None" = None
class ComposerValidationWarningResponse(ResponseModel):
code: str
surface: str | None = None
kind: str | None = None
id: str | None = None
message: str | None = None
class ComposerKnowledgePlaceholderResponse(ResponseModel):
id: str
placeholder_name: str
class ComposerValidationFindingsResponse(ResponseModel):
warnings: list[ComposerValidationWarningResponse] = Field(default_factory=list)
knowledge_retrieval_placeholder: list[ComposerKnowledgePlaceholderResponse] = Field(default_factory=list)
class AgentComposerValidateResponse(ResponseModel):
result: Literal["success"]
errors: list[str] = Field(default_factory=list)
warnings: list[ComposerValidationWarningResponse] = Field(default_factory=list)
knowledge_retrieval_placeholder: list[ComposerKnowledgePlaceholderResponse] = Field(default_factory=list)
class AgentComposerDifyToolCandidateResponse(ResponseModel):
@ -181,6 +204,20 @@ class AgentComposerDifyToolCandidateResponse(ResponseModel):
plugin_id: str | None = None
class AgentComposerSkillCandidateResponse(AgentSkillRefConfig):
kind: Literal["skill"] = "skill"
class AgentComposerFileCandidateResponse(AgentFileRefConfig):
kind: Literal["file"] = "file"
AgentComposerSkillFileCandidateResponse = Annotated[
AgentComposerSkillCandidateResponse | AgentComposerFileCandidateResponse,
Field(discriminator="kind"),
]
class AgentComposerNodeJobCandidatesResponse(ResponseModel):
previous_node_outputs: list[WorkflowPreviousNodeOutputRef] = Field(default_factory=list)
declare_output_types: list[DeclaredOutputType] = Field(default_factory=list)
@ -188,7 +225,7 @@ class AgentComposerNodeJobCandidatesResponse(ResponseModel):
class AgentComposerSoulCandidatesResponse(ResponseModel):
skills_files: list[AgentSkillRefConfig] = Field(default_factory=list)
skills_files: list[AgentComposerSkillFileCandidateResponse] = Field(default_factory=list)
dify_tools: list[AgentComposerDifyToolCandidateResponse] = Field(default_factory=list)
cli_tools: list[AgentCliToolConfig] = Field(default_factory=list)
knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
@ -204,3 +241,4 @@ class AgentComposerCandidatesResponse(ResponseModel):
default_factory=AgentComposerSoulCandidatesResponse
)
capabilities: ComposerCandidateCapabilities = Field(default_factory=ComposerCandidateCapabilities)
truncated: bool = False

View File

@ -11808,6 +11808,7 @@ Get banner list
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes |
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
| validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No |
| variant | string | | Yes |
#### AgentAppFeaturesPayload
@ -11903,6 +11904,7 @@ Risk marker for CLI tool bootstrap commands.
| allowed_node_job_candidates | [AgentComposerNodeJobCandidatesResponse](#agentcomposernodejobcandidatesresponse) | | No |
| allowed_soul_candidates | [AgentComposerSoulCandidatesResponse](#agentcomposersoulcandidatesresponse) | | No |
| capabilities | [ComposerCandidateCapabilities](#composercandidatecapabilities) | | No |
| truncated | boolean | | No |
| variant | [ComposerVariant](#composervariant) | | Yes |
#### AgentComposerDifyToolCandidateResponse
@ -11916,6 +11918,22 @@ Risk marker for CLI tool bootstrap commands.
| provider | string | | No |
| provider_id | string | | No |
#### AgentComposerFileCandidateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| file_id | string | | No |
| id | string | | No |
| kind | string | | No |
| name | string | | No |
| reference | string | | No |
| remote_url | string | | No |
| tenant_id | string | | No |
| transfer_method | string | | No |
| type | string | | No |
| upload_file_id | string | | No |
| url | string | | No |
#### AgentComposerImpactBindingResponse
| Name | Type | Description | Required |
@ -11940,6 +11958,17 @@ Risk marker for CLI tool bootstrap commands.
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
| previous_node_outputs | [ [WorkflowPreviousNodeOutputRef](#workflowpreviousnodeoutputref) ] | | No |
#### AgentComposerSkillCandidateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| file_id | string | | No |
| id | string | | No |
| kind | string | | No |
| name | string | | No |
| path | string | | No |
#### AgentComposerSoulCandidatesResponse
| Name | Type | Description | Required |
@ -11948,7 +11977,7 @@ Risk marker for CLI tool bootstrap commands.
| dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No |
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
| knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
| skills_files | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No |
| skills_files | [ ] | | No |
#### AgentComposerSoulLockResponse
@ -11963,7 +11992,9 @@ Risk marker for CLI tool bootstrap commands.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| errors | [ string ] | | No |
| knowledge_retrieval_placeholder | [ [ComposerKnowledgePlaceholderResponse](#composerknowledgeplaceholderresponse) ] | | No |
| result | string | | Yes |
| warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No |
#### AgentConfigRevisionOperation
@ -13286,6 +13317,13 @@ Button styles for user actions.
| ---- | ---- | ----------- | -------- |
| human_roster_available | boolean | | No |
#### ComposerKnowledgePlaceholderResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| id | string | | Yes |
| placeholder_name | string | | Yes |
#### ComposerSavePayload
| Name | Type | Description | Required |
@ -13314,6 +13352,23 @@ Button styles for user actions.
| locked | boolean | | No |
| unlocked_from_version_id | string | | No |
#### ComposerValidationFindingsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| knowledge_retrieval_placeholder | [ [ComposerKnowledgePlaceholderResponse](#composerknowledgeplaceholderresponse) ] | | No |
| warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No |
#### ComposerValidationWarningResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| code | string | | Yes |
| id | string | | No |
| kind | string | | No |
| message | string | | No |
| surface | string | | No |
#### ComposerVariant
| Name | Type | Description | Required |
@ -17571,6 +17626,7 @@ How a workflow node is bound to an Agent.
| node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | Yes |
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
| soul_lock | [AgentComposerSoulLockResponse](#agentcomposersoullockresponse) | | Yes |
| validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No |
| variant | string | | Yes |
| workflow_id | string | | No |

View File

@ -0,0 +1,210 @@
"""Slash-menu candidates assembly (ENG-615).
Pure assembly over injected loaders so the upstream-graph computation and the
per-source mapping are unit-testable without a database. IO wiring (draft
workflow / bindings / draft variables / datasets / workspace tools) lives in
``AgentComposerService.get_*_candidates``.
``previous_node_outputs`` entries are emitted in the stored
``WorkflowPreviousNodeOutputRef`` shape (``selector``/``node_id``/``output``/
``name``) so the frontend can write a selected candidate back into
``node_job.previous_node_output_refs`` verbatim; display extras
(``node_title``/``node_kind``/``value_type``/``inferred``) ride along via the
flexible config schema. Output enumeration follows the Node Output Inspector:
start variables + recorded ``sys.*`` variables are static, Agent v2 nodes use
their binding's declared outputs, and every other node kind is inferred from
the latest draft-run variables (``inferred: true``).
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Any
from models.agent_config_entities import (
AgentSoulConfig,
DeclaredOutputConfig,
)
MAX_CANDIDATES_PER_LIST = 200
_SYSTEM_NODE_ID = "sys"
# loader signatures injected by the service layer
DeclaredOutputsLoader = Callable[[str], list[DeclaredOutputConfig] | None]
DraftVariablesLoader = Callable[[str], list[tuple[str, str | None]]]
SystemVariablesLoader = Callable[[], list[tuple[str, str | None]]]
DatasetLookup = Callable[[list[str]], Mapping[str, Any]]
WorkspaceToolsLoader = Callable[[], list[dict[str, Any]]]
def previous_node_output_candidates(
*,
graph: Mapping[str, Any],
node_id: str,
declared_outputs_loader: DeclaredOutputsLoader,
draft_variables_loader: DraftVariablesLoader,
system_variables_loader: SystemVariablesLoader,
) -> tuple[list[dict[str, Any]], bool]:
"""Enumerate upstream node outputs for ``node_id`` as writable ref candidates."""
from core.workflow.graph_topology import WorkflowGraphTopology
topology = WorkflowGraphTopology.from_graph(graph)
upstream = topology.upstream_node_ids(node_id)
entries: list[dict[str, Any]] = []
for name, value_type in system_variables_loader():
entries.append(
_ref_entry(
node_id=_SYSTEM_NODE_ID,
output=name,
node_title="System",
node_kind="system",
value_type=value_type,
inferred=True,
)
)
nodes = graph.get("nodes")
for node in nodes if isinstance(nodes, list) else []:
if not isinstance(node, Mapping):
continue
nid = node.get("id")
if not isinstance(nid, str) or nid not in upstream:
continue
raw_data = node.get("data")
data: Mapping[str, Any] = raw_data if isinstance(raw_data, Mapping) else {}
kind = str(data.get("type") or "unknown")
title = str(data.get("title") or nid)
if kind == "start":
for variable in data.get("variables") or []:
if not isinstance(variable, Mapping):
continue
var_name = variable.get("variable")
if isinstance(var_name, str) and var_name:
entries.append(
_ref_entry(
node_id=nid,
output=var_name,
node_title=title,
node_kind=kind,
value_type=variable.get("type") if isinstance(variable.get("type"), str) else None,
inferred=False,
)
)
continue
declared: list[DeclaredOutputConfig] | None = None
if kind == "agent" and str(data.get("version", "")) == "2":
declared = declared_outputs_loader(nid)
if declared is not None:
for output in declared:
entries.append(
_ref_entry(
node_id=nid,
output=output.name,
node_title=title,
node_kind=kind,
value_type=output.type.value,
inferred=False,
)
)
continue
for var_name, value_type in draft_variables_loader(nid):
entries.append(
_ref_entry(
node_id=nid,
output=var_name,
node_title=title,
node_kind=kind,
value_type=value_type,
inferred=True,
)
)
return _capped(entries)
def soul_candidates(
*,
agent_soul: AgentSoulConfig | None,
dataset_lookup: DatasetLookup,
workspace_tools_loader: WorkspaceToolsLoader,
) -> tuple[dict[str, list[dict[str, Any]]], bool]:
"""Assemble the soul-surface candidate lists (design §3.2)."""
soul = agent_soul or AgentSoulConfig()
truncated = False
skills_files = [{"kind": "skill", **skill.model_dump(exclude_none=True)} for skill in soul.skills_files.skills]
skills_files += [{"kind": "file", **file.model_dump(exclude_none=True)} for file in soul.skills_files.files]
cli_tools = [tool.model_dump(exclude_none=True) for tool in soul.tools.cli_tools if tool.enabled]
dataset_ids = [dataset.id for dataset in soul.knowledge.datasets if dataset.id]
dataset_rows = dataset_lookup(dataset_ids) if dataset_ids else {}
knowledge_datasets: list[dict[str, Any]] = []
for dataset in soul.knowledge.datasets:
if not dataset.id:
continue
row = dataset_rows.get(dataset.id)
knowledge_datasets.append(
{
"id": dataset.id,
"name": (getattr(row, "name", None) or dataset.name or dataset.id),
"description": getattr(row, "description", None) or dataset.description,
"missing": row is None,
}
)
human_contacts = [contact.model_dump(exclude_none=True) for contact in soul.human.contacts]
dify_tools = workspace_tools_loader()
lists = {
"skills_files": skills_files,
"dify_tools": dify_tools,
"cli_tools": cli_tools,
"knowledge_datasets": knowledge_datasets,
"human_contacts": human_contacts,
}
capped: dict[str, list[dict[str, Any]]] = {}
for key, values in lists.items():
clipped, was_clipped = _capped(values)
truncated = truncated or was_clipped
capped[key] = clipped
return capped, truncated
def _ref_entry(
*,
node_id: str,
output: str,
node_title: str,
node_kind: str,
value_type: str | None,
inferred: bool,
) -> dict[str, Any]:
return {
"selector": [node_id, output],
"node_id": node_id,
"output": output,
"name": f"{node_title}/{output}",
"node_title": node_title,
"node_kind": node_kind,
"value_type": value_type,
"inferred": inferred,
}
def _capped(values: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]:
if len(values) > MAX_CANDIDATES_PER_LIST:
return values[:MAX_CANDIDATES_PER_LIST], True
return values, False
__all__ = [
"MAX_CANDIDATES_PER_LIST",
"previous_node_output_candidates",
"soul_candidates",
]

View File

@ -1,3 +1,4 @@
import logging
from typing import Any
from sqlalchemy import func, select
@ -39,6 +40,8 @@ from services.entities.agent_entities import (
# Mirrors Workflow.version when it is "draft" (see models/workflow.py).
_DRAFT_WORKFLOW_VERSION = "draft"
logger = logging.getLogger(__name__)
class AgentComposerService:
@classmethod
@ -108,7 +111,9 @@ class AgentComposerService:
agent_id=agent.id if agent else None,
version_id=binding.current_snapshot_id,
)
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
state = cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload)
return state
@classmethod
def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]:
@ -205,42 +210,241 @@ class AgentComposerService:
agent.updated_by = account_id
db.session.commit()
return cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload)
return state
@classmethod
def get_workflow_candidates(cls, *, app_id: str) -> dict[str, Any]:
def collect_validation_findings(cls, *, tenant_id: str, payload: ComposerSavePayload) -> dict[str, Any]:
"""ENG-617 soft findings, with DB-backed dataset existence for placeholders."""
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
mentioned_ids: set[str] = set()
if payload.agent_soul is not None:
mentioned_ids |= {
mention.ref_id
for mention in parse_prompt_mentions(payload.agent_soul.prompt.system_prompt)
if mention.kind == MentionKind.KNOWLEDGE
}
existing_dataset_ids: set[str] | None = None
if mentioned_ids:
existing_dataset_ids = set(cls._dataset_rows(tenant_id=tenant_id, dataset_ids=sorted(mentioned_ids)))
return ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids)
@classmethod
def get_workflow_candidates(cls, *, tenant_id: str, app_id: str, node_id: str, user_id: str) -> dict[str, Any]:
"""Slash-menu data source for the workflow Agent node composer (ENG-615)."""
from services.agent.composer_candidates import previous_node_output_candidates, soul_candidates
try:
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
except ValueError:
workflow = None
node_job: WorkflowNodeJobConfig | None = None
agent_soul: AgentSoulConfig | None = None
if workflow is not None:
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
if binding is not None:
node_job = cls._parse_node_job(binding)
agent_soul = cls._load_binding_soul(tenant_id=tenant_id, binding=binding)
truncated = False
previous_outputs: list[dict[str, Any]] = []
if workflow is not None:
draft_variable_session = cls._draft_variable_session()
try:
previous_outputs, outputs_truncated = previous_node_output_candidates(
graph=workflow.graph_dict,
node_id=node_id,
declared_outputs_loader=lambda nid: cls._binding_declared_outputs(
tenant_id=tenant_id, workflow_id=workflow.id, node_id=nid
),
draft_variables_loader=lambda nid: cls._draft_node_variables(
session=draft_variable_session, app_id=app_id, node_id=nid, user_id=user_id
),
system_variables_loader=lambda: cls._draft_system_variables(
session=draft_variable_session, app_id=app_id, user_id=user_id
),
)
finally:
draft_variable_session.close()
truncated = truncated or outputs_truncated
soul_lists, soul_truncated = soul_candidates(
agent_soul=agent_soul,
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
)
truncated = truncated or soul_truncated
response = ComposerCandidatesResponse(
variant=ComposerVariant.WORKFLOW,
allowed_node_job_candidates={
"previous_node_outputs": [],
"previous_node_outputs": previous_outputs,
"declare_output_types": ["string", "number", "object", "array", "boolean", "file"],
"human_contacts": [],
},
allowed_soul_candidates={
"skills_files": [],
"dify_tools": [],
"cli_tools": [],
"knowledge_datasets": [],
"human_contacts": [],
"human_contacts": [
contact.model_dump(exclude_none=True) for contact in (node_job.human_contacts if node_job else [])
],
},
allowed_soul_candidates=soul_lists,
truncated=truncated,
)
return response.model_dump(mode="json")
@classmethod
def get_agent_app_candidates(cls, *, app_id: str) -> dict[str, Any]:
def get_agent_app_candidates(cls, *, tenant_id: str, app_id: str, user_id: str) -> dict[str, Any]:
"""Slash-menu data source for the Agent App (Console) composer (ENG-615)."""
from services.agent.composer_candidates import soul_candidates
agent_soul = cls._load_agent_app_soul(tenant_id=tenant_id, app_id=app_id)
soul_lists, truncated = soul_candidates(
agent_soul=agent_soul,
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
)
response = ComposerCandidatesResponse(
variant=ComposerVariant.AGENT_APP,
allowed_node_job_candidates={},
allowed_soul_candidates={
"skills_files": [],
"dify_tools": [],
"cli_tools": [],
"knowledge_datasets": [],
"human_contacts": [],
},
allowed_soul_candidates=soul_lists,
truncated=truncated,
)
return response.model_dump(mode="json")
# ── candidates IO helpers (ENG-615) ──────────────────────────────────────
@staticmethod
def _parse_node_job(binding: WorkflowAgentNodeBinding) -> WorkflowNodeJobConfig | None:
try:
return WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
except Exception:
logger.warning("candidates: malformed node_job_config for binding %s", binding.id, exc_info=True)
return None
@classmethod
def _load_binding_soul(cls, *, tenant_id: str, binding: WorkflowAgentNodeBinding) -> AgentSoulConfig | None:
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id)
version = cls._get_version_if_present(
tenant_id=tenant_id,
agent_id=agent.id if agent else None,
version_id=binding.current_snapshot_id,
)
return cls._parse_soul_snapshot(version)
@classmethod
def _load_agent_app_soul(cls, *, tenant_id: str, app_id: str) -> AgentSoulConfig | None:
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
Agent.app_id == app_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
)
.order_by(Agent.created_at.desc())
.limit(1)
)
if agent is None:
return None
version = cls._get_version_if_present(
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
)
return cls._parse_soul_snapshot(version)
@staticmethod
def _parse_soul_snapshot(version: AgentConfigSnapshot | None) -> AgentSoulConfig | None:
if version is None:
return None
try:
return AgentSoulConfig.model_validate(version.config_snapshot_dict)
except Exception:
logger.warning("candidates: malformed soul snapshot %s", version.id, exc_info=True)
return None
@classmethod
def _binding_declared_outputs(
cls, *, tenant_id: str, workflow_id: str, node_id: str
) -> list[DeclaredOutputConfig] | None:
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow_id, node_id=node_id)
if binding is None:
return None
node_job = cls._parse_node_job(binding)
if node_job is None:
return None
return list(_effective_declared_outputs(node_job.declared_outputs))
@staticmethod
def _draft_variable_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=db.engine, expire_on_commit=False)()
@staticmethod
def _draft_node_variables(*, session: Any, app_id: str, node_id: str, user_id: str) -> list[tuple[str, str | None]]:
from services.workflow_draft_variable_service import WorkflowDraftVariableService
variables = WorkflowDraftVariableService(session=session).list_node_variables(app_id, node_id, user_id)
return [(variable.name, variable.value_type.value) for variable in variables.variables]
@staticmethod
def _draft_system_variables(*, session: Any, app_id: str, user_id: str) -> list[tuple[str, str | None]]:
from services.workflow_draft_variable_service import WorkflowDraftVariableService
variables = WorkflowDraftVariableService(session=session).list_system_variables(app_id, user_id)
return [(variable.name, variable.value_type.value) for variable in variables.variables]
@staticmethod
def _dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
"""Tenant-scoped dataset lookup tolerating malformed ids.
Mention ids come from user-editable prompt text; a non-UUID id can never
match a dataset row, so it is simply absent from the result (-> missing/
placeholder semantics) instead of breaking the UUID-typed query.
"""
from uuid import UUID
from services.dataset_service import DatasetService
valid_ids: list[str] = []
for dataset_id in dataset_ids:
try:
UUID(dataset_id)
except (ValueError, TypeError):
continue
valid_ids.append(dataset_id)
if not valid_ids:
return {}
rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
return {str(row.id): row for row in rows}
@staticmethod
def _workspace_dify_tools(*, tenant_id: str, user_id: str) -> list[dict[str, Any]]:
"""Workspace Dify Plugin tools, same source as the tool selector.
A plugin-daemon outage must degrade the slash menu to an empty tools
tab, not break the whole candidates endpoint.
"""
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
try:
providers = BuiltinToolManageService.list_builtin_tools(user_id, tenant_id)
except Exception:
logger.warning("candidates: failed to list workspace tools for tenant %s", tenant_id, exc_info=True)
return []
tools: list[dict[str, Any]] = []
for provider in providers:
for tool in provider.tools or []:
tools.append(
{
"id": f"{provider.name}/{tool.name}",
"name": tool.name,
"description": tool.label.en_US if tool.label else tool.name,
"provider": provider.name,
"plugin_id": provider.plugin_id or None,
}
)
return tools
@classmethod
def calculate_impact(cls, *, tenant_id: str, current_snapshot_id: str) -> dict[str, Any]:
bindings = list(

View File

@ -4,6 +4,17 @@ from typing import Any
from pydantic import ValidationError
from services.agent.errors import AgentSoulLockedError, InvalidComposerConfigError, PlaintextSecretNotAllowedError
from services.agent.prompt_mentions import (
MAX_MENTIONS_PER_PROMPT,
NODE_JOB_PROMPT_ALLOWED_KINDS,
SOUL_PROMPT_ALLOWED_KINDS,
MentionKind,
MentionResolver,
build_node_job_mention_resolver,
build_soul_mention_resolver,
find_malformed_mention_markers,
parse_prompt_mentions,
)
from services.entities.agent_entities import (
AgentSoulConfig,
ComposerSavePayload,
@ -46,6 +57,158 @@ class ComposerConfigValidator:
cls.validate_agent_soul(payload.agent_soul)
if payload.node_job is not None:
cls.validate_node_job(payload.node_job)
cls._validate_prompt_mentions(payload)
@classmethod
def _validate_prompt_mentions(cls, payload: ComposerSavePayload) -> None:
"""ENG-616 §2.4 allowlists + ENG-617 §5.2 human-must-be-referenced.
Error messages start with a stable code token (``mention_kind_not_allowed``
/ ``mention_limit_exceeded`` / ``human_involvement_not_referenced``) so
the frontend can switch on it.
"""
if payload.agent_soul is not None:
cls._validate_surface_mentions(
prompt=payload.agent_soul.prompt.system_prompt,
allowed=SOUL_PROMPT_ALLOWED_KINDS,
surface="agent soul prompt",
)
cls._require_human_mentions(
prompt=payload.agent_soul.prompt.system_prompt,
contacts=payload.agent_soul.human.contacts,
surface="agent soul prompt",
)
if payload.node_job is not None:
cls._validate_surface_mentions(
prompt=payload.node_job.workflow_prompt,
allowed=NODE_JOB_PROMPT_ALLOWED_KINDS,
surface="workflow job prompt",
)
cls._require_human_mentions(
prompt=payload.node_job.workflow_prompt,
contacts=payload.node_job.human_contacts,
surface="workflow job prompt",
)
@classmethod
def _validate_surface_mentions(cls, *, prompt: str, allowed: frozenset[MentionKind], surface: str) -> None:
mentions = parse_prompt_mentions(prompt)
if len(mentions) > MAX_MENTIONS_PER_PROMPT:
raise InvalidComposerConfigError(
f"mention_limit_exceeded: {surface} has {len(mentions)} mentions, "
f"exceeding the limit of {MAX_MENTIONS_PER_PROMPT}."
)
for mention in mentions:
if mention.kind not in allowed:
raise InvalidComposerConfigError(
f"mention_kind_not_allowed: {surface} cannot reference {mention.kind.value} (id={mention.ref_id})."
)
@classmethod
def _require_human_mentions(cls, *, prompt: str, contacts: list[Any], surface: str) -> None:
"""ENG-617 §5.2 (PRD: human involvement must be slash-referenced or save errors).
Every configured human contact must appear as ``{{#human:<id>#}}`` in the
corresponding prompt. A contact matches via any identity alias; contacts
carrying no identity at all cannot be referenced and are skipped.
"""
if not contacts:
return
mentioned = {mention.ref_id for mention in parse_prompt_mentions(prompt) if mention.kind == MentionKind.HUMAN}
for contact in contacts:
aliases = {
alias
for alias in (contact.id, contact.contact_id, contact.human_id, contact.email, contact.name)
if alias
}
if not aliases:
continue
if aliases.isdisjoint(mentioned):
display = contact.name or contact.email or contact.id or "human involvement"
raise InvalidComposerConfigError(
f"human_involvement_not_referenced: configured human involvement '{display}' "
f"must be referenced in the {surface} via the slash menu."
)
@classmethod
def collect_soft_findings(
cls,
payload: ComposerSavePayload,
*,
existing_dataset_ids: set[str] | None = None,
) -> dict[str, Any]:
"""ENG-617 §5.3/§5.4 soft findings — never block save.
``warnings`` carries ``mention_target_missing`` / ``mention_malformed``
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge
mentions with a placeholder name (0522 consensus) instead of dropping or
rejecting them. With ``existing_dataset_ids`` provided, configured-but-
deleted datasets surface as placeholders too.
"""
warnings: list[dict[str, Any]] = []
placeholders: list[dict[str, str]] = []
surfaces: list[tuple[str, str, MentionResolver, frozenset[MentionKind]]] = []
if payload.agent_soul is not None:
surfaces.append(
(
"agent_soul",
payload.agent_soul.prompt.system_prompt,
build_soul_mention_resolver(payload.agent_soul),
SOUL_PROMPT_ALLOWED_KINDS,
)
)
if payload.node_job is not None:
surfaces.append(
(
"node_job",
payload.node_job.workflow_prompt,
build_node_job_mention_resolver(payload.node_job),
NODE_JOB_PROMPT_ALLOWED_KINDS,
)
)
for surface, prompt, resolver, allowed in surfaces:
for mention in parse_prompt_mentions(prompt):
if mention.kind not in allowed:
continue # hard-rejected by validate_save_payload
resolved = resolver(mention)
if mention.kind == MentionKind.KNOWLEDGE:
dangling = resolved is None or (
existing_dataset_ids is not None and mention.ref_id not in existing_dataset_ids
)
if dangling:
placeholders.append(
{
"id": mention.ref_id,
"placeholder_name": mention.label or f"Knowledge {mention.ref_id[:8]}",
}
)
continue
if resolved is None:
warnings.append(
{
"code": "mention_target_missing",
"surface": surface,
"kind": mention.kind.value,
"id": mention.ref_id,
"message": f"{mention.kind.value} mention (id={mention.ref_id}) does not match "
"any configured item.",
}
)
for marker in find_malformed_mention_markers(prompt):
warnings.append(
{
"code": "mention_malformed",
"surface": surface,
"kind": None,
"id": None,
"message": f"mention-shaped marker {marker!r} is malformed and will be "
"degraded to plain text at runtime.",
}
)
return {"warnings": warnings, "knowledge_retrieval_placeholder": placeholders}
@classmethod
def validate_agent_soul(cls, agent_soul: AgentSoulConfig) -> None:

View File

@ -0,0 +1,264 @@
"""Prompt mention (slash-reference) serialization contract — ENG-616.
Slash-menu insertions are stored inline in the plain-string prompt as tokens:
[§<kind>:<id>[:<label>]§]
``kind`` is a fixed lowercase word; ``id`` points at an item in the Agent config
lists (mentions are pointers the entity itself lives in ``skills_files`` /
``tools`` / ``knowledge.datasets`` / ``human.contacts`` /
``previous_node_output_refs`` / ``declared_outputs``); ``label`` is an optional
plain-text fallback only (the backend always re-resolves by id, so renames never
break references). A single ``:`` separates all three fields; ``label`` is the
trailing remainder and may itself contain ``:``.
The ``[§§]`` wrapper uses the section sign ``§`` (U+00A7), which never appears
in Dify template syntax (``{{var}}`` / ``{{#a.b#}}``) nor in normal prompt text,
so these tokens can never collide with the existing template parsers. Runtime
expansion (and the final scrub that guarantees no internal marker ever reaches
the model) is owned by the run-request builders.
"""
from __future__ import annotations
import re
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from models.agent_config_entities import (
AgentHumanContactConfig,
AgentSoulConfig,
WorkflowNodeJobConfig,
WorkflowPreviousNodeOutputRef,
)
class MentionKind(StrEnum):
SKILL = "skill"
FILE = "file"
TOOL = "tool"
CLI_TOOL = "cli_tool"
KNOWLEDGE = "knowledge"
HUMAN = "human"
NODE_OUTPUT = "node_output"
OUTPUT = "output"
MENTION_PATTERN = re.compile(
r"\[§(skill|file|tool|cli_tool|knowledge|human|node_output|output):([^:§]+?)(?::([^§]*?))?§\]"
)
# Anything mention-shaped (``[§word:…§]``) that the strict pattern did not consume
# — unknown kinds, malformed bodies. The ``§`` wrapper + a kind-word + ``:``
# requirement keeps legacy ``{{#histories#}}`` / ``{{var}}`` template forms and
# ordinary bracketed text out of scope.
_RESIDUAL_MENTION_PATTERN = re.compile(r"\[§([A-Za-z_][A-Za-z0-9_]*:[^§]*?)§\]")
MAX_MENTIONS_PER_PROMPT = 200
MAX_MENTION_FIELD_LENGTH = 255
# 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(
{
MentionKind.SKILL,
MentionKind.FILE,
MentionKind.TOOL,
MentionKind.CLI_TOOL,
MentionKind.KNOWLEDGE,
MentionKind.HUMAN,
}
)
NODE_JOB_PROMPT_ALLOWED_KINDS = frozenset({MentionKind.NODE_OUTPUT, MentionKind.OUTPUT, MentionKind.HUMAN})
@dataclass(frozen=True, slots=True)
class PromptMention:
kind: MentionKind
ref_id: str
label: str | None
start: int
end: int
raw: str
# Returns the model-readable replacement for a mention, or None when the id does
# not resolve (the expander then degrades to label/id).
MentionResolver = Callable[[PromptMention], str | None]
def parse_prompt_mentions(prompt: str) -> list[PromptMention]:
"""Extract well-formed mentions. Oversized id/label tokens are skipped here
(treated as malformed) the runtime scrub still degrades them safely."""
mentions: list[PromptMention] = []
for match in MENTION_PATTERN.finditer(prompt or ""):
ref_id = match.group(2)
label = match.group(3)
if len(ref_id) > MAX_MENTION_FIELD_LENGTH or (label is not None and len(label) > MAX_MENTION_FIELD_LENGTH):
continue
mentions.append(
PromptMention(
kind=MentionKind(match.group(1)),
ref_id=ref_id,
label=label or None,
start=match.start(),
end=match.end(),
raw=match.group(0),
)
)
return mentions
def expand_prompt_mentions(prompt: str, resolver: MentionResolver) -> str:
"""Replace every mention with resolver output, degrading unresolved ones to
their label (then id), and scrub any residual mention-shaped marker so no
frontend-internal token ever reaches the model."""
if not prompt:
return prompt
def _replace(match: re.Match[str]) -> str:
ref_id = match.group(2)
label = match.group(3) or None
fallback = (label or ref_id)[:MAX_MENTION_FIELD_LENGTH]
if len(ref_id) > MAX_MENTION_FIELD_LENGTH or (label is not None and len(label) > MAX_MENTION_FIELD_LENGTH):
return fallback
mention = PromptMention(
kind=MentionKind(match.group(1)),
ref_id=ref_id,
label=label,
start=match.start(),
end=match.end(),
raw=match.group(0),
)
resolved = resolver(mention)
if resolved is None or not resolved.strip():
return fallback
return resolved[:MAX_MENTION_FIELD_LENGTH]
return scrub_mention_markers(MENTION_PATTERN.sub(_replace, prompt))
def find_malformed_mention_markers(prompt: str) -> list[str]:
"""Mention-shaped markers the strict grammar does not accept (unknown kind,
oversized id/label, broken body). Soft-flagged at validate; the runtime
scrub still degrades them safely."""
if not prompt:
return []
parsed_spans = {(mention.start, mention.end) for mention in parse_prompt_mentions(prompt)}
return [match.group(0) for match in _RESIDUAL_MENTION_PATTERN.finditer(prompt) if match.span() not in parsed_spans]
def scrub_mention_markers(text: str) -> str:
"""Degrade any residual mention-shaped ``[§kind:…§]`` marker to readable text."""
def _degrade(match: re.Match[str]) -> str:
# inner is ``kind:id[:label]``; prefer the label, else the id.
parts = match.group(1).split(":", 2)
if len(parts) >= 3 and parts[2].strip():
return parts[2].strip()[:MAX_MENTION_FIELD_LENGTH]
if len(parts) >= 2 and parts[1].strip():
return parts[1].strip()[:MAX_MENTION_FIELD_LENGTH]
return match.group(1)[:MAX_MENTION_FIELD_LENGTH]
return _RESIDUAL_MENTION_PATTERN.sub(_degrade, text)
def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
"""Resolve soul-surface mentions to canonical display names from the soul config."""
def _resolve(mention: PromptMention) -> str | None:
match mention.kind:
case MentionKind.SKILL:
for skill in agent_soul.skills_files.skills:
if mention.ref_id in (skill.id, skill.name):
return skill.name or skill.id
case MentionKind.FILE:
for file in agent_soul.skills_files.files:
if mention.ref_id in (file.id, file.name):
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
}
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
case MentionKind.KNOWLEDGE:
for dataset in agent_soul.knowledge.datasets:
if mention.ref_id == dataset.id:
return dataset.name or dataset.id
case MentionKind.HUMAN:
return _resolve_human_contact(agent_soul.human.contacts, mention.ref_id)
case _:
return None
return None
return _resolve
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)."""
def _resolve(mention: PromptMention) -> str | None:
match mention.kind:
case MentionKind.NODE_OUTPUT:
for ref in node_job.previous_node_output_refs:
selector = _selector_from_ref(ref)
if selector and f"{selector[0]}.{selector[1]}" == mention.ref_id:
return ref.name or mention.label or mention.ref_id
case MentionKind.OUTPUT:
for output in node_job.declared_outputs:
if output.name == mention.ref_id:
return f"{output.name} ({output.type.value})"
case MentionKind.HUMAN:
return _resolve_human_contact(node_job.human_contacts, mention.ref_id)
case _:
return None
return None
return _resolve
def _resolve_human_contact(contacts: list[AgentHumanContactConfig], ref_id: str) -> str | None:
for contact in contacts:
if ref_id in (contact.id, contact.contact_id, contact.human_id):
channel = contact.channel or contact.method or contact.contact_method
who = contact.name or contact.email or ref_id
return f"{channel.upper()} · {who}" if channel else who
return None
def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, str] | None:
for candidate in (ref.selector, ref.variable_selector, ref.value_selector):
if isinstance(candidate, list) and len(candidate) >= 2:
return str(candidate[0]), str(candidate[1])
if ref.node_id:
output = ref.output or ref.variable or ref.key
if output:
return ref.node_id, output
return None
__all__ = [
"MAX_MENTIONS_PER_PROMPT",
"MAX_MENTION_FIELD_LENGTH",
"MENTION_PATTERN",
"NODE_JOB_PROMPT_ALLOWED_KINDS",
"SOUL_PROMPT_ALLOWED_KINDS",
"MentionKind",
"MentionResolver",
"PromptMention",
"build_node_job_mention_resolver",
"build_soul_mention_resolver",
"expand_prompt_mentions",
"find_malformed_mention_markers",
"parse_prompt_mentions",
"scrub_mention_markers",
]

View File

@ -91,3 +91,5 @@ class ComposerCandidatesResponse(BaseModel):
allowed_node_job_candidates: dict[str, Any] = Field(default_factory=dict)
allowed_soul_candidates: dict[str, Any] = Field(default_factory=dict)
capabilities: ComposerCandidateCapabilities = Field(default_factory=ComposerCandidateCapabilities)
# True when any candidate list was clipped to the per-list cap (ENG-615 §3.3).
truncated: bool = False

View File

@ -24,6 +24,7 @@ from controllers.console.agent.roster import (
AgentRosterVersionDetailApi,
AgentRosterVersionsApi,
)
from models.model import AppMode
from services.entities.agent_entities import ComposerSaveStrategy, ComposerVariant
@ -111,6 +112,22 @@ def _candidates_response(variant: str) -> dict:
}
def _get_app_model_modes(view) -> list[AppMode]:
current = view
while current is not None:
closure = getattr(current, "__closure__", None)
if closure is not None:
for cell in closure:
try:
value = cell.cell_contents
except ValueError:
continue
if isinstance(value, list) and all(isinstance(item, AppMode) for item in value):
return value
current = getattr(current, "__wrapped__", None)
return []
class _PayloadWithDescription(Protocol):
description: object
@ -289,12 +306,12 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
)
assert saved_state["save_options"] == ["node_job_only"]
assert unwrap(WorkflowAgentComposerValidateApi.post)(
WorkflowAgentComposerValidateApi(), app_model, "node-1"
) == {"result": "success", "errors": []}
WorkflowAgentComposerValidateApi(), "tenant-1", app_model, "node-1"
) == {"result": "success", "errors": [], "warnings": [], "knowledge_retrieval_placeholder": []}
assert (
unwrap(WorkflowAgentComposerCandidatesApi.get)(WorkflowAgentComposerCandidatesApi(), app_model, "node-1")[
"variant"
]
unwrap(WorkflowAgentComposerCandidatesApi.get)(
WorkflowAgentComposerCandidatesApi(), "tenant-1", account_id, app_model, "node-1"
)["variant"]
== "workflow"
)
with app.test_request_context(json=payload):
@ -349,9 +366,20 @@ def test_agent_app_composer_get_put_validate_and_candidates(
unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), "tenant-1", account_id, app_model)["variant"]
== "agent_app"
)
assert unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), app_model) == {
assert unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), "tenant-1", app_model) == {
"result": "success",
"errors": [],
"warnings": [],
"knowledge_retrieval_placeholder": [],
}
agent_app_candidates = unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model)
agent_app_candidates = unwrap(AgentAppComposerCandidatesApi.get)(
AgentAppComposerCandidatesApi(), "tenant-1", account_id, app_model
)
assert agent_app_candidates["variant"] == "agent_app"
def test_agent_app_composer_routes_are_agent_mode_only() -> None:
assert _get_app_model_modes(AgentAppComposerApi.get) == [AppMode.AGENT]
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]

View File

@ -599,3 +599,40 @@ def test_effective_declared_outputs_passthrough_when_user_declared():
declared = [DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING)]
effective = WorkflowAgentRuntimeRequestBuilder.effective_declared_outputs(declared)
assert list(effective) == declared
def test_mentions_expand_in_soul_and_job_prompts_without_token_leak():
"""ENG-616: slash-menu mention tokens expand to canonical names; node_output
mentions expand to the reference name only (the value stays in the Workflow
context user prompt), and no ``[§§]`` marker leaks into the request."""
import json
context = _context()
context.snapshot.config_snapshot = AgentSoulConfig(
prompt={"system_prompt": "Careful. Ask [§human:c-1:EMAIL · DAVE§] when unsure."},
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
human={"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
)
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
{
"workflow_prompt": (
"Read [§node_output:previous-node.text:PREV/text§] and produce [§output:summary§]. "
"Unknown [§knowledge:gone:旧手册§] degrades."
),
"previous_node_output_refs": [
{"selector": ["previous-node", "text"], "name": "PREV/text"},
],
"declared_outputs": [{"name": "summary", "type": "string"}],
}
)
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
dumped = result.request.model_dump(mode="json")
assert dumped["composition"]["layers"][0]["config"]["prefix"] == ("Careful. Ask EMAIL · David Hayes when unsure.")
assert dumped["composition"]["layers"][1]["config"]["prefix"] == (
"Read PREV/text and produce summary (string). Unknown 旧手册 degrades."
)
# the value still rides the Workflow context block, not the job prompt
assert "Previous result" in dumped["composition"]["layers"][2]["config"]["user"]
assert "" not in json.dumps(dumped["composition"]["layers"][:3])

View File

@ -0,0 +1,51 @@
"""Unit tests for the shared workflow graph topology helper (ENG-615)."""
from __future__ import annotations
from core.workflow.graph_topology import WorkflowGraphTopology
_GRAPH = {
"nodes": [
{"id": "start"},
{"id": "llm-1"},
{"id": "llm-2"},
{"id": "agent"},
{"id": "end"},
],
"edges": [
{"source": "start", "target": "llm-1"},
{"source": "start", "target": "llm-2"},
{"source": "llm-1", "target": "agent"},
{"source": "llm-2", "target": "agent"},
{"source": "agent", "target": "end"},
# ghost edge: source node was deleted from nodes[]
{"source": "ghost", "target": "agent"},
],
}
def test_upstream_node_ids_collects_all_ancestors_excluding_ghosts():
topology = WorkflowGraphTopology.from_graph(_GRAPH)
assert topology.upstream_node_ids("agent") == {"start", "llm-1", "llm-2"}
def test_upstream_node_ids_differ_per_target_node():
topology = WorkflowGraphTopology.from_graph(_GRAPH)
assert topology.upstream_node_ids("llm-1") == {"start"}
assert topology.upstream_node_ids("end") == {"start", "llm-1", "llm-2", "agent"}
assert topology.upstream_node_ids("start") == set()
def test_is_upstream_kept_for_publish_validation():
topology = WorkflowGraphTopology.from_graph(_GRAPH)
assert topology.is_upstream(source_node_id="start", target_node_id="end")
assert not topology.is_upstream(source_node_id="end", target_node_id="start")
def test_cycle_safe():
graph = {
"nodes": [{"id": "a"}, {"id": "b"}],
"edges": [{"source": "a", "target": "b"}, {"source": "b", "target": "a"}],
}
topology = WorkflowGraphTopology.from_graph(graph)
assert topology.upstream_node_ids("a") == {"b"}

View File

@ -148,6 +148,7 @@ def test_save_workflow_composer_dispatches_save_strategy(monkeypatch, strategy,
tenant_id="tenant-1", app_id="app-1", node_id="node-1", account_id="account-1", payload=payload
)
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
assert result == {"state": "ok"}
assert calls
assert fake_session.commits == 1
@ -189,6 +190,7 @@ def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch):
tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload
)
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
assert result == {"loaded": True}
assert fake_session.added[0].name == "Analyst"
assert fake_session.added[0].active_config_snapshot_id == "version-1"
@ -222,6 +224,7 @@ def test_save_agent_app_composer_updates_current_version(monkeypatch):
tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload
)
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
assert result == {"loaded": True}
assert updated["operation"].value == "save_current_version"
assert fake_session._scalar == []
@ -235,12 +238,28 @@ def test_agent_app_composer_candidates_and_impact(monkeypatch):
]
monkeypatch.setattr(composer_service.db, "session", FakeSession(scalars=[bindings]))
workflow_candidates = AgentComposerService.get_workflow_candidates(app_id="app-1")
agent_app_candidates = AgentComposerService.get_agent_app_candidates(app_id="app-1")
# Candidates assembly is covered in test_composer_candidates.py; here we stub
# the IO loaders and assert the response envelope per variant (ENG-615).
def _no_draft_workflow(**kwargs):
raise ValueError("draft workflow not found")
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", _no_draft_workflow)
monkeypatch.setattr(AgentComposerService, "_load_agent_app_soul", lambda **kwargs: None)
monkeypatch.setattr(AgentComposerService, "_workspace_dify_tools", lambda **kwargs: [])
workflow_candidates = AgentComposerService.get_workflow_candidates(
tenant_id="tenant-1", app_id="app-1", node_id="node-1", user_id="account-1"
)
agent_app_candidates = AgentComposerService.get_agent_app_candidates(
tenant_id="tenant-1", app_id="app-1", user_id="account-1"
)
impact = AgentComposerService.calculate_impact(tenant_id="tenant-1", current_snapshot_id="version-1")
assert workflow_candidates["variant"] == "workflow"
assert workflow_candidates["allowed_node_job_candidates"]["previous_node_outputs"] == []
assert workflow_candidates["truncated"] is False
assert agent_app_candidates["variant"] == "agent_app"
assert agent_app_candidates["allowed_soul_candidates"]["dify_tools"] == []
assert impact["workflow_node_count"] == 2
assert impact["bindings"][1]["node_id"] == "node-2"
@ -875,3 +894,27 @@ class TestListWorkflowsReferencingAppAgent:
service = AgentRosterService(session)
assert service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-1") == []
def test_dataset_rows_filters_malformed_ids(monkeypatch):
"""Mention ids are user-editable text: a non-UUID id must read as missing
(placeholder semantics), never reach the UUID-typed dataset query (E2E 500)."""
captured = {}
def fake_get_datasets_by_ids(ids, tenant_id):
captured["ids"] = ids
return [], 0
import services.dataset_service as dataset_service_module
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
valid = "550e8400-e29b-41d4-a716-446655440000"
rows = AgentComposerService._dataset_rows(tenant_id="tenant-1", dataset_ids=["9999dead-beef", valid])
assert rows == {}
assert captured["ids"] == [valid]
# all-malformed input never touches the DB
captured.clear()
assert AgentComposerService._dataset_rows(tenant_id="tenant-1", dataset_ids=["nope"]) == {}
assert captured == {}

View File

@ -0,0 +1,204 @@
"""Unit tests for slash-menu candidates assembly (ENG-615)."""
from __future__ import annotations
from types import SimpleNamespace
from fields.agent_fields import AgentComposerCandidatesResponse
from models.agent_config_entities import AgentSoulConfig, DeclaredOutputConfig, DeclaredOutputType
from services.agent.composer_candidates import (
MAX_CANDIDATES_PER_LIST,
previous_node_output_candidates,
soul_candidates,
)
_GRAPH = {
"nodes": [
{
"id": "start-1",
"data": {
"type": "start",
"title": "START",
"variables": [{"variable": "tenders", "type": "file-list"}],
},
},
{"id": "llm-1", "data": {"type": "llm", "title": "LLM"}},
{"id": "agent-up", "data": {"type": "agent", "version": "2", "title": "Upstream Agent"}},
{"id": "agent-target", "data": {"type": "agent", "version": "2", "title": "Target Agent"}},
{"id": "end", "data": {"type": "end", "title": "END"}},
],
"edges": [
{"source": "start-1", "target": "llm-1"},
{"source": "llm-1", "target": "agent-up"},
{"source": "agent-up", "target": "agent-target"},
{"source": "agent-target", "target": "end"},
],
}
def _declared_loader(nid: str) -> list[DeclaredOutputConfig] | None:
if nid == "agent-up":
return [DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING)]
return None
def _draft_vars(nid: str) -> list[tuple[str, str | None]]:
if nid == "llm-1":
return [("text", "string")]
return []
def _collect(node_id: str, *, system_vars=()):
entries, truncated = previous_node_output_candidates(
graph=_GRAPH,
node_id=node_id,
declared_outputs_loader=_declared_loader,
draft_variables_loader=_draft_vars,
system_variables_loader=lambda: list(system_vars),
)
return entries, truncated
def test_upstream_outputs_follow_inspector_semantics():
entries, truncated = _collect("agent-target", system_vars=[("query", "string")])
assert truncated is False
by_node = {}
for entry in entries:
by_node.setdefault(entry["node_id"], []).append(entry)
# sys vars ride as a pseudo node, run-derived
assert by_node["sys"][0]["selector"] == ["sys", "query"]
assert by_node["sys"][0]["inferred"] is True
# start variables are static graph facts
start = by_node["start-1"][0]
assert start["selector"] == ["start-1", "tenders"]
assert start["name"] == "START/tenders"
assert start["inferred"] is False
assert start["value_type"] == "file-list"
# agent v2 upstream node uses its declared outputs
agent = by_node["agent-up"][0]
assert agent["output"] == "summary"
assert agent["value_type"] == "string"
assert agent["inferred"] is False
# other kinds fall back to draft variables (inferred)
llm = by_node["llm-1"][0]
assert llm["output"] == "text"
assert llm["inferred"] is True
# the target node itself and downstream nodes never appear
assert "agent-target" not in by_node
assert "end" not in by_node
def test_results_differ_per_node_id():
entries_target, _ = _collect("agent-target")
entries_llm, _ = _collect("llm-1")
assert {e["node_id"] for e in entries_target} == {"start-1", "llm-1", "agent-up"}
assert {e["node_id"] for e in entries_llm} == {"start-1"}
def test_previous_outputs_capped_and_flagged():
graph = {
"nodes": [{"id": "start-1", "data": {"type": "start", "title": "S", "variables": []}}, {"id": "t"}],
"edges": [{"source": "start-1", "target": "t"}],
}
many: list[tuple[str, str | None]] = [(f"v{i}", "string") for i in range(MAX_CANDIDATES_PER_LIST + 5)]
entries, truncated = previous_node_output_candidates(
graph=graph,
node_id="t",
declared_outputs_loader=lambda nid: None,
draft_variables_loader=lambda nid: [],
system_variables_loader=lambda: many,
)
assert len(entries) == MAX_CANDIDATES_PER_LIST
assert truncated is True
def _soul() -> AgentSoulConfig:
return AgentSoulConfig.model_validate(
{
"skills_files": {
"skills": [{"id": "sk-1", "name": "tender-analyzer"}],
"files": [{"id": "f-1", "name": "qna_report.pdf"}],
},
"tools": {
"cli_tools": [{"name": "ffmpeg"}, {"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"}]},
}
)
def test_soul_candidates_lists_configured_items_only():
lists, truncated = soul_candidates(
agent_soul=_soul(),
dataset_lookup=lambda ids: {"ds-1": SimpleNamespace(name="产品手册", description="desc")},
workspace_tools_loader=lambda: [
{"id": "tavily/tavily_search", "name": "tavily_search", "provider": "tavily", "plugin_id": "lg/tavily"}
],
)
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"]
# 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"] == "产品手册"
assert knowledge["ds-1"]["missing"] is False
assert knowledge["ds-gone"]["missing"] is True
assert knowledge["ds-gone"]["name"] == "已删"
assert lists["human_contacts"][0]["id"] == "c-1"
assert lists["dify_tools"][0]["id"] == "tavily/tavily_search"
def test_candidates_response_preserves_skill_and_file_candidate_shapes():
response = AgentComposerCandidatesResponse.model_validate(
{
"variant": "agent_app",
"allowed_node_job_candidates": {},
"allowed_soul_candidates": {
"skills_files": [
{"kind": "skill", "id": "sk-1", "name": "tender-analyzer", "path": "skills/tender.md"},
{
"kind": "file",
"id": "f-1",
"name": "qna_report.pdf",
"transfer_method": "local_file",
"reference": "upload-1",
"url": "https://files.example/qna_report.pdf",
},
]
},
"capabilities": {"human_roster_available": False},
}
).model_dump(mode="json")
skill, file = response["allowed_soul_candidates"]["skills_files"]
assert skill["kind"] == "skill"
assert skill["path"] == "skills/tender.md"
assert file["kind"] == "file"
assert file["transfer_method"] == "local_file"
assert file["reference"] == "upload-1"
assert file["url"] == "https://files.example/qna_report.pdf"
def test_soul_candidates_empty_config_yields_empty_lists():
lists, truncated = soul_candidates(
agent_soul=None,
dataset_lookup=lambda ids: {},
workspace_tools_loader=lambda: [],
)
assert truncated is False
assert all(value == [] for value in lists.values())
def test_soul_candidates_caps_lists():
lists, truncated = soul_candidates(
agent_soul=None,
dataset_lookup=lambda ids: {},
workspace_tools_loader=lambda: [{"id": str(i)} for i in range(MAX_CANDIDATES_PER_LIST + 1)],
)
assert len(lists["dify_tools"]) == MAX_CANDIDATES_PER_LIST
assert truncated is True

View File

@ -0,0 +1,188 @@
"""Composer save/validate mention rules (ENG-616 §2.4 allowlists)."""
from __future__ import annotations
import pytest
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.errors import InvalidComposerConfigError
from services.entities.agent_entities import ComposerSavePayload
def _soul_payload(system_prompt: str) -> ComposerSavePayload:
return ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"agent_soul": {"prompt": {"system_prompt": system_prompt}},
"save_strategy": "save_to_current_version",
}
)
def _node_job_payload(workflow_prompt: str) -> ComposerSavePayload:
return ComposerSavePayload.model_validate(
{
"variant": "workflow",
"node_job": {"workflow_prompt": workflow_prompt},
"save_strategy": "node_job_only",
}
)
def test_soul_prompt_accepts_soul_kinds():
payload = _soul_payload("Use [§skill:s1§] [§file:f1§] [§tool:p/t§] [§cli_tool:c§] [§knowledge:k1§] [§human:h1§]")
ComposerConfigValidator.validate_save_payload(payload)
def test_soul_prompt_rejects_node_output_mention():
with pytest.raises(InvalidComposerConfigError, match="mention_kind_not_allowed"):
ComposerConfigValidator.validate_save_payload(_soul_payload("Read [§node_output:n1.text§]"))
def test_soul_prompt_rejects_declared_output_mention():
with pytest.raises(InvalidComposerConfigError, match="mention_kind_not_allowed"):
ComposerConfigValidator.validate_save_payload(_soul_payload("Produce [§output:report§]"))
def test_node_job_prompt_accepts_job_kinds():
payload = _node_job_payload("Read [§node_output:n1.text:START/text§], produce [§output:report§], ask [§human:h1§]")
ComposerConfigValidator.validate_save_payload(payload)
@pytest.mark.parametrize("token", ["[§skill:s1§]", "[§tool:p/t§]", "[§cli_tool:c§]", "[§knowledge:k1§]"])
def test_node_job_prompt_rejects_soul_only_kinds(token: str):
with pytest.raises(InvalidComposerConfigError, match="mention_kind_not_allowed"):
ComposerConfigValidator.validate_save_payload(_node_job_payload(f"Use {token}"))
def test_mention_limit_enforced():
prompt = " ".join(f"[§human:h{i}§]" for i in range(201))
with pytest.raises(InvalidComposerConfigError, match="mention_limit_exceeded"):
ComposerConfigValidator.validate_save_payload(_soul_payload(prompt))
def test_prompt_without_mentions_still_passes():
ComposerConfigValidator.validate_save_payload(_soul_payload("plain prompt, {{var}} and {{#context#}} untouched"))
# ── ENG-617: human must be referenced (hard) ─────────────────────────────────
def _soul_payload_with_human(system_prompt: str) -> ComposerSavePayload:
return ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"agent_soul": {
"prompt": {"system_prompt": system_prompt},
"human": {
"contacts": [{"id": "c-1", "name": "David Hayes", "email": "david@acme.com", "channel": "email"}]
},
},
"save_strategy": "save_to_current_version",
}
)
def test_configured_human_without_mention_is_rejected():
with pytest.raises(InvalidComposerConfigError, match="human_involvement_not_referenced"):
ComposerConfigValidator.validate_save_payload(_soul_payload_with_human("no human reference here"))
def test_configured_human_referenced_by_id_passes():
ComposerConfigValidator.validate_save_payload(_soul_payload_with_human("ask [§human:c-1§] when unsure"))
def test_configured_human_referenced_by_email_alias_passes():
ComposerConfigValidator.validate_save_payload(_soul_payload_with_human("ask [§human:david@acme.com§]"))
def test_node_job_human_must_be_referenced_too():
payload = ComposerSavePayload.model_validate(
{
"variant": "workflow",
"node_job": {
"workflow_prompt": "do the work",
"human_contacts": [{"id": "c-2", "name": "Reviewer"}],
},
"save_strategy": "node_job_only",
}
)
with pytest.raises(InvalidComposerConfigError, match="human_involvement_not_referenced"):
ComposerConfigValidator.validate_save_payload(payload)
payload.node_job.workflow_prompt = "escalate to [§human:c-2§]"
ComposerConfigValidator.validate_save_payload(payload)
def test_identity_less_human_contact_is_skipped():
payload = ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"agent_soul": {
"prompt": {"system_prompt": "plain"},
"human": {"contacts": [{"channel": "email"}]},
},
"save_strategy": "save_to_current_version",
}
)
ComposerConfigValidator.validate_save_payload(payload)
# ── ENG-617: soft findings ───────────────────────────────────────────────────
def _findings(payload: ComposerSavePayload, **kwargs):
return ComposerConfigValidator.collect_soft_findings(payload, **kwargs)
def test_dangling_knowledge_mention_becomes_placeholder_with_label():
payload = _soul_payload("ground in [§knowledge:gone-1:旧产品手册§]")
findings = _findings(payload)
assert findings["knowledge_retrieval_placeholder"] == [{"id": "gone-1", "placeholder_name": "旧产品手册"}]
assert findings["warnings"] == []
def test_dangling_knowledge_without_label_gets_fallback_name():
findings = _findings(_soul_payload("see [§knowledge:deadbeef-cafe§]"))
assert findings["knowledge_retrieval_placeholder"] == [
{"id": "deadbeef-cafe", "placeholder_name": "Knowledge deadbeef"}
]
def test_configured_but_deleted_dataset_surfaces_as_placeholder():
payload = ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"agent_soul": {
"prompt": {"system_prompt": "see [§knowledge:ds-1:产品手册§]"},
"knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]},
},
"save_strategy": "save_to_current_version",
}
)
# configured + DB row exists -> clean
assert _findings(payload, existing_dataset_ids={"ds-1"})["knowledge_retrieval_placeholder"] == []
# configured but deleted in DB -> placeholder
assert _findings(payload, existing_dataset_ids=set())["knowledge_retrieval_placeholder"] == [
{"id": "ds-1", "placeholder_name": "产品手册"}
]
def test_unresolved_non_knowledge_mentions_warn_target_missing():
findings = _findings(_soul_payload("use [§skill:nope:Ghost Skill§] and [§human:missing§]"))
codes = [(w["code"], w["kind"]) for w in findings["warnings"]]
assert ("mention_target_missing", "skill") in codes
assert ("mention_target_missing", "human") in codes
assert findings["knowledge_retrieval_placeholder"] == []
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
findings = _findings(payload)
assert [w["code"] for w in findings["warnings"]] == ["mention_malformed"]
def test_clean_prompt_yields_empty_findings():
findings = _findings(_soul_payload("plain prompt with {{#context#}} legacy form"))
assert findings == {"warnings": [], "knowledge_retrieval_placeholder": []}

View File

@ -0,0 +1,179 @@
"""Unit tests for the prompt mention contract (ENG-616).
Token form: ``[§<kind>:<id>[:<label>]§]``. Mentions are pointers into the Agent
config lists; expansion replaces them with canonical names and the scrub pass
guarantees no mention-shaped marker survives to the model.
"""
from __future__ import annotations
import pytest
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
from services.agent.prompt_mentions import (
MAX_MENTION_FIELD_LENGTH,
NODE_JOB_PROMPT_ALLOWED_KINDS,
SOUL_PROMPT_ALLOWED_KINDS,
MentionKind,
build_node_job_mention_resolver,
build_soul_mention_resolver,
expand_prompt_mentions,
parse_prompt_mentions,
scrub_mention_markers,
)
# ── parse ─────────────────────────────────────────────────────────────────────
def test_parse_extracts_kind_id_and_optional_label():
prompt = "Use [§skill:abc-1:tender-analyzer§] then ask [§human:c-1§]."
mentions = parse_prompt_mentions(prompt)
assert [(m.kind, m.ref_id, m.label) for m in mentions] == [
(MentionKind.SKILL, "abc-1", "tender-analyzer"),
(MentionKind.HUMAN, "c-1", None),
]
assert prompt[mentions[0].start : mentions[0].end] == mentions[0].raw
def test_parse_supports_ids_with_slash_and_dot():
mentions = parse_prompt_mentions("[§tool:langgenius/tavily/tavily_search:tavily§] [§node_output:node-1.tenders§]")
assert mentions[0].ref_id == "langgenius/tavily/tavily_search"
assert mentions[1].ref_id == "node-1.tenders"
def test_parse_ignores_legacy_template_forms_and_unknown_kinds():
prompt = "{{var}} {{#context#}} {{#sys.query#}} [§bogus_kind:x§]"
assert parse_prompt_mentions(prompt) == []
def test_parse_skips_oversized_id_or_label():
long_id = "x" * (MAX_MENTION_FIELD_LENGTH + 1)
assert parse_prompt_mentions(f"[§skill:{long_id}§]") == []
# ── expand + scrub ────────────────────────────────────────────────────────────
def test_expand_uses_resolver_and_degrades_unresolved_to_label_then_id():
prompt = "A [§skill:s1:Skill One§] B [§human:h1:EMAIL · DAVE§] C [§knowledge:k1§]"
def resolver(mention):
return "resolved-skill" if mention.kind == MentionKind.SKILL else None
expanded = expand_prompt_mentions(prompt, resolver)
assert expanded == "A resolved-skill B EMAIL · DAVE C k1"
assert "" not in expanded
def test_expand_scrubs_unknown_kind_tokens_but_keeps_legacy_forms():
prompt = "x [§wat:id-1:Label§] y {{#context#}} z {{#node.var#}}"
expanded = expand_prompt_mentions(prompt, lambda m: None)
# unknown mention-shaped token degraded to its label; legacy forms untouched
assert expanded == "x Label y {{#context#}} z {{#node.var#}}"
def test_scrub_degrades_colon_tokens_without_label_to_id_part():
assert scrub_mention_markers("see [§weird_kind:some-id§]") == "see some-id"
def test_expand_empty_prompt_is_noop():
assert expand_prompt_mentions("", lambda m: "x") == ""
# ── soul resolver ─────────────────────────────────────────────────────────────
@pytest.fixture
def soul() -> AgentSoulConfig:
return AgentSoulConfig.model_validate(
{
"skills_files": {
"skills": [{"id": "sk-1", "name": "tender-analyzer"}],
"files": [{"id": "f-1", "name": "qna_report.pdf"}],
},
"tools": {
"dify_tools": [
{
"plugin_id": "langgenius/tavily",
"provider": "tavily",
"tool_name": "tavily_search",
"credential_type": "unauthorized",
},
],
"cli_tools": [{"name": "ffmpeg"}],
},
"knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]},
"human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
}
)
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§], "
"ground in [§knowledge:ds-1§], ask [§human:c-1§]."
)
expanded = expand_prompt_mentions(prompt, resolver)
assert expanded == (
"Use tender-analyzer with qna_report.pdf, search via tavily_search, "
"run ffmpeg, ground in 产品手册, ask EMAIL · David Hayes."
)
def test_soul_resolver_unknown_ids_degrade(soul: AgentSoulConfig):
expanded = expand_prompt_mentions("[§knowledge:missing:旧产品手册§]", build_soul_mention_resolver(soul))
assert expanded == "旧产品手册"
# ── node-job resolver ─────────────────────────────────────────────────────────
@pytest.fixture
def node_job() -> WorkflowNodeJobConfig:
return WorkflowNodeJobConfig.model_validate(
{
"workflow_prompt": "",
"previous_node_output_refs": [{"selector": ["start-1", "tenders"], "name": "START/tenders"}],
# declared output names are JSON-schema-friendly identifiers (no dots)
"declared_outputs": [{"name": "qna_report", "type": "file"}],
"human_contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}],
}
)
def test_node_job_resolver_resolves_each_kind(node_job: WorkflowNodeJobConfig):
resolver = build_node_job_mention_resolver(node_job)
prompt = "Read [§node_output:start-1.tenders§] and produce [§output:qna_report§]; if unsure contact [§human:c-1§]."
expanded = expand_prompt_mentions(prompt, resolver)
assert expanded == ("Read START/tenders and produce qna_report (file); if unsure contact EMAIL · David Hayes.")
def test_node_job_resolver_matches_ref_by_node_id_and_output_fields():
node_job = WorkflowNodeJobConfig.model_validate(
{"previous_node_output_refs": [{"node_id": "n-2", "output": "text"}]}
)
expanded = expand_prompt_mentions("[§node_output:n-2.text:LLM/text§]", build_node_job_mention_resolver(node_job))
# ref has no display name -> degrade to the mention label
assert expanded == "LLM/text"
# ── allowlists ────────────────────────────────────────────────────────────────
def test_per_surface_allowlists_match_design():
assert {
MentionKind.SKILL,
MentionKind.FILE,
MentionKind.TOOL,
MentionKind.CLI_TOOL,
MentionKind.KNOWLEDGE,
MentionKind.HUMAN,
} == SOUL_PROMPT_ALLOWED_KINDS
assert {MentionKind.NODE_OUTPUT, MentionKind.OUTPUT, MentionKind.HUMAN} == NODE_JOB_PROMPT_ALLOWED_KINDS

View File

@ -173,6 +173,7 @@ export type AgentAppComposerResponse = {
agent: AgentComposerAgentResponse
agent_soul: AgentSoulConfig
save_options: Array<ComposerSaveStrategy>
validation?: ComposerValidationFindingsResponse
variant: string
}
@ -193,12 +194,15 @@ export type AgentComposerCandidatesResponse = {
allowed_node_job_candidates?: AgentComposerNodeJobCandidatesResponse
allowed_soul_candidates?: AgentComposerSoulCandidatesResponse
capabilities?: ComposerCandidateCapabilities
truncated?: boolean
variant: ComposerVariant
}
export type AgentComposerValidateResponse = {
errors?: Array<string>
knowledge_retrieval_placeholder?: Array<ComposerKnowledgePlaceholderResponse>
result: string
warnings?: Array<ComposerValidationWarningResponse>
}
export type AgentAppFeaturesPayload = {
@ -797,6 +801,7 @@ export type WorkflowAgentComposerResponse = {
node_job: WorkflowNodeJobConfig
save_options: Array<ComposerSaveStrategy>
soul_lock: AgentComposerSoulLockResponse
validation?: ComposerValidationFindingsResponse
variant: string
workflow_id?: string | null
}
@ -1099,6 +1104,11 @@ export type ComposerSaveStrategy
| 'save_to_current_version'
| 'save_to_roster'
export type ComposerValidationFindingsResponse = {
knowledge_retrieval_placeholder?: Array<ComposerKnowledgePlaceholderResponse>
warnings?: Array<ComposerValidationWarningResponse>
}
export type ComposerBindingPayload = {
agent_id?: string | null
binding_type: 'inline_agent' | 'roster_agent'
@ -1133,13 +1143,26 @@ export type AgentComposerSoulCandidatesResponse = {
dify_tools?: Array<AgentComposerDifyToolCandidateResponse>
human_contacts?: Array<AgentHumanContactConfig>
knowledge_datasets?: Array<AgentKnowledgeDatasetConfig>
skills_files?: Array<AgentSkillRefConfig>
skills_files?: Array<unknown>
}
export type ComposerCandidateCapabilities = {
human_roster_available?: boolean
}
export type ComposerKnowledgePlaceholderResponse = {
id: string
placeholder_name: string
}
export type ComposerValidationWarningResponse = {
code: string
id?: string | null
kind?: string | null
message?: string | null
surface?: string | null
}
export type AgentFeatureToggleConfig = {
enabled?: boolean
[key: string]: unknown
@ -1732,15 +1755,31 @@ export type AgentKnowledgeDatasetConfig = {
[key: string]: unknown
}
export type AgentSkillRefConfig = {
export type AgentComposerSkillCandidateResponse = {
description?: string | null
file_id?: string | null
id?: string | null
kind?: string
name?: string | null
path?: string | null
[key: string]: unknown
}
export type AgentComposerFileCandidateResponse = {
file_id?: string | null
id?: string | null
kind?: string
name?: string | null
reference?: string | null
remote_url?: string | null
tenant_id?: string | null
transfer_method?: string | null
type?: string | null
upload_file_id?: string | null
url?: string | null
[key: string]: unknown
}
export type AgentModerationProviderConfig = {
api_based_extension_id?: string | null
inputs_config?: AgentModerationIoConfig
@ -1941,6 +1980,15 @@ export type AgentFileRefConfig = {
[key: string]: unknown
}
export type AgentSkillRefConfig = {
description?: string | null
file_id?: string | null
id?: string | null
name?: string | null
path?: string | null
[key: string]: unknown
}
export type AgentSoulDifyToolConfig = {
credential_ref?: AgentSoulDifyToolCredentialRef
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'

View File

@ -77,14 +77,6 @@ export const zAdvancedChatWorkflowRunPayload = z.object({
query: z.string().optional().default(''),
})
/**
* AgentComposerValidateResponse
*/
export const zAgentComposerValidateResponse = z.object({
errors: z.array(z.string()).optional(),
result: z.string(),
})
/**
* SimpleResultResponse
*/
@ -802,6 +794,43 @@ export const zComposerCandidateCapabilities = z.object({
human_roster_available: z.boolean().optional().default(false),
})
/**
* ComposerKnowledgePlaceholderResponse
*/
export const zComposerKnowledgePlaceholderResponse = z.object({
id: z.string(),
placeholder_name: z.string(),
})
/**
* ComposerValidationWarningResponse
*/
export const zComposerValidationWarningResponse = z.object({
code: z.string(),
id: z.string().nullish(),
kind: z.string().nullish(),
message: z.string().nullish(),
surface: z.string().nullish(),
})
/**
* AgentComposerValidateResponse
*/
export const zAgentComposerValidateResponse = z.object({
errors: z.array(z.string()).optional(),
knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(),
result: z.string(),
warnings: z.array(zComposerValidationWarningResponse).optional(),
})
/**
* ComposerValidationFindingsResponse
*/
export const zComposerValidationFindingsResponse = z.object({
knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(),
warnings: z.array(zComposerValidationWarningResponse).optional(),
})
/**
* AgentFeatureToggleConfig
*/
@ -1763,16 +1792,34 @@ export const zAgentKnowledgeDatasetConfig = z.object({
})
/**
* AgentSkillRefConfig
* AgentComposerSkillCandidateResponse
*/
export const zAgentSkillRefConfig = z.object({
export const zAgentComposerSkillCandidateResponse = z.object({
description: z.string().nullish(),
file_id: z.string().max(255).nullish(),
id: z.string().max(255).nullish(),
kind: z.string().optional().default('skill'),
name: z.string().max(255).nullish(),
path: z.string().nullish(),
})
/**
* AgentComposerFileCandidateResponse
*/
export const zAgentComposerFileCandidateResponse = z.object({
file_id: z.string().max(255).nullish(),
id: z.string().max(255).nullish(),
kind: z.string().optional().default('file'),
name: z.string().max(255).nullish(),
reference: z.string().max(255).nullish(),
remote_url: z.string().nullish(),
tenant_id: z.string().max(255).nullish(),
transfer_method: z.string().max(64).nullish(),
type: z.string().max(64).nullish(),
upload_file_id: z.string().max(255).nullish(),
url: z.string().nullish(),
})
/**
* SimpleModelConfig
*/
@ -2156,14 +2203,6 @@ export const zAgentFileRefConfig = z.object({
url: z.string().nullish(),
})
/**
* AgentSoulSkillsFilesConfig
*/
export const zAgentSoulSkillsFilesConfig = z.object({
files: z.array(zAgentFileRefConfig).optional(),
skills: z.array(zAgentSkillRefConfig).optional(),
})
/**
* WorkflowNodeJobMetadata
*/
@ -2172,6 +2211,25 @@ export const zWorkflowNodeJobMetadata = z.object({
file_refs: z.array(zAgentFileRefConfig).nullish(),
})
/**
* AgentSkillRefConfig
*/
export const zAgentSkillRefConfig = z.object({
description: z.string().nullish(),
file_id: z.string().max(255).nullish(),
id: z.string().max(255).nullish(),
name: z.string().max(255).nullish(),
path: z.string().nullish(),
})
/**
* AgentSoulSkillsFilesConfig
*/
export const zAgentSoulSkillsFilesConfig = z.object({
files: z.array(zAgentFileRefConfig).optional(),
skills: z.array(zAgentSkillRefConfig).optional(),
})
/**
* AgentCliToolAuthorizationStatus
*
@ -2270,7 +2328,7 @@ export const zAgentComposerSoulCandidatesResponse = z.object({
dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(),
human_contacts: z.array(zAgentHumanContactConfig).optional(),
knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
skills_files: z.array(zAgentSkillRefConfig).optional(),
skills_files: z.array(z.unknown()).optional(),
})
/**
@ -2280,6 +2338,7 @@ export const zAgentComposerCandidatesResponse = z.object({
allowed_node_job_candidates: zAgentComposerNodeJobCandidatesResponse.optional(),
allowed_soul_candidates: zAgentComposerSoulCandidatesResponse.optional(),
capabilities: zComposerCandidateCapabilities.optional(),
truncated: z.boolean().optional().default(false),
variant: zComposerVariant,
})
@ -2557,6 +2616,7 @@ export const zAgentAppComposerResponse = z.object({
agent: zAgentComposerAgentResponse,
agent_soul: zAgentSoulConfig,
save_options: z.array(zComposerSaveStrategy),
validation: zComposerValidationFindingsResponse.optional(),
variant: z.string(),
})
@ -2591,6 +2651,7 @@ export const zWorkflowAgentComposerResponse = z.object({
node_job: zWorkflowNodeJobConfig,
save_options: z.array(zComposerSaveStrategy),
soul_lock: zAgentComposerSoulLockResponse,
validation: zComposerValidationFindingsResponse.optional(),
variant: z.string(),
workflow_id: z.string().nullish(),
})