mirror of
https://github.com/langgenius/dify.git
synced 2026-06-11 10:57:40 +08:00
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:
parent
6658a7c5e7
commit
2c5c8e82c3
@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
86
api/core/workflow/graph_topology.py
Normal file
86
api/core/workflow/graph_topology.py
Normal 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"]
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 |
|
||||
|
||||
|
||||
210
api/services/agent/composer_candidates.py
Normal file
210
api/services/agent/composer_candidates.py
Normal 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",
|
||||
]
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
264
api/services/agent/prompt_mentions.py
Normal file
264
api/services/agent/prompt_mentions.py
Normal 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",
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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])
|
||||
|
||||
51
api/tests/unit_tests/core/workflow/test_graph_topology.py
Normal file
51
api/tests/unit_tests/core/workflow/test_graph_topology.py
Normal 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"}
|
||||
@ -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 == {}
|
||||
|
||||
204
api/tests/unit_tests/services/agent/test_composer_candidates.py
Normal file
204
api/tests/unit_tests/services/agent/test_composer_candidates.py
Normal 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
|
||||
@ -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": []}
|
||||
179
api/tests/unit_tests/services/agent/test_prompt_mentions.py
Normal file
179
api/tests/unit_tests/services/agent/test_prompt_mentions.py
Normal 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
|
||||
@ -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'
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user