feat(agent): add output routes to Agent v2 (#42599)

Co-authored-by: zhsama <torvalds@linux.do>
This commit is contained in:
盐粒 Yanliandzhsama authored and GitHub committed 2026-09-22 21:28:07 +00:00
1 parent a068c47ea9
commit 0f6a6227f5
114 files changed
+2888 -536

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
"""Parse frontend workflow markers shared by Agent tasks and route validation.
These helpers distinguish previous-node references from reserved workflow
namespaces without depending on application services or resolving values.
"""
from __future__ import annotations
import re
from models.agent_config_entities import WorkflowPreviousNodeOutputRef
WORKFLOW_VARIABLE_PATTERN = re.compile(r"\{\{#([^{}#]+?\.[^{}#]+?)#\}\}")
WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES = frozenset(
{"sys", "env", "conversation", "rag", "current", "last_run", "error_message", "$output"}
)
def extract_workflow_variable_selectors(prompt: str) -> list[tuple[str, ...]]:
"""Extract ``{{#node.output#}}``-style selectors from workflow prompts."""
selectors: list[tuple[str, ...]] = []
for match in WORKFLOW_VARIABLE_PATTERN.finditer(prompt or ""):
parts = tuple(part.strip() for part in match.group(1).split(".") if part.strip())
if len(parts) >= 2:
selectors.append(parts)
return selectors
def extract_workflow_node_output_selectors(prompt: str) -> list[tuple[str, ...]]:
"""Extract previous-node selectors from frontend workflow variable markers.
Reserved Dify namespaces such as ``sys`` are excluded because they are not
previous nodes.
"""
selectors: list[tuple[str, ...]] = []
seen: set[tuple[str, ...]] = set()
for selector in extract_workflow_variable_selectors(prompt):
if selector[0] in WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES:
continue
if selector in seen:
continue
selectors.append(selector)
seen.add(selector)
return selectors
def workflow_previous_node_output_refs_from_selectors(
selectors: list[tuple[str, ...]],
) -> list[WorkflowPreviousNodeOutputRef]:
"""Materialize persisted previous-node refs from parsed frontend selectors."""
return [
WorkflowPreviousNodeOutputRef(
selector=list(selector),
node_id=selector[0],
output=selector[1],
)
for selector in selectors
]
+58 -14
View File
@@ -29,11 +29,18 @@ from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from core.workflow.nodes.human_input.session_binding import default_session_binding
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.entities.pause_reason import HitlRequired, SchedulingPause
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
from graphon.enums import (
BuiltinNodeTypes,
ErrorStrategy,
NodeExecutionType,
WorkflowNodeExecutionMetadataKey,
WorkflowNodeExecutionStatus,
)
from graphon.graph_events import NodeRunPauseRequestedEvent
from graphon.node_events import NodeEventBase, NodeRunResult, StreamCompletedEvent
from graphon.nodes.base.node import Node
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
from graphon.nodes.base.variable_template_parser import VariableTemplateParser
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig, WorkflowOutputRoutes
from services.agent.prompt_mentions import extract_workflow_node_output_selectors
from services.agent.workspace_service import AgentWorkspaceNotFoundError
@@ -75,6 +82,14 @@ type _TerminalAgentBackendEvent = (
class DifyAgentNode(Node[DifyAgentNodeData]):
"""Execute a frozen node job and select one workflow exit on success.
Enabled routing selects a stable route ID through the required ``switch``
output. Disabled routing remains executable so
graph-owned default values can continue without a selected route; Graphon
still promotes nodes that opt into its failure branch.
"""
node_type = BuiltinNodeTypes.AGENT
def __init__(
@@ -99,6 +114,11 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
)
# The base node parses the factory's serialized payload into node_data.
if self.node_data.agent_output_routes.enabled and self.error_strategy == ErrorStrategy.DEFAULT_VALUE:
raise ValueError("Output routes do not support the node-level default-value error strategy.")
if self.node_data.agent_output_routes.enabled:
self.execution_type = NodeExecutionType.BRANCH
self._binding_resolver = binding_resolver
self._runtime_request_builder = runtime_request_builder
self._agent_backend_client = agent_backend_client
@@ -229,6 +249,7 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
)
node_job = WorkflowNodeJobConfig.model_validate(bundle.binding.node_job_config_dict)
node_job.output_routes.validate_for_execution()
custom_outputs = list(node_job.declared_outputs)
outputs_by_name = {output.name: output for output in custom_outputs}
@@ -490,9 +511,27 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
)
return
# Select only after this attempt's custom outputs have passed. Failed
# route output uses the node's normal retry / failure-branch handling.
routes = node_job.output_routes
edge_source_handle = "source"
if routes.enabled:
selected = success_event.output.get("switch") if isinstance(success_event.output, dict) else None
if not isinstance(selected, str) or selected not in {route.id for route in routes.routes}:
yield self._failure_event(
inputs=inputs,
process_data=process_data,
metadata=metadata,
error="Agent output switch must select one configured output route ID.",
error_type="output_route_selection_failed",
)
return
edge_source_handle = selected
yield StreamCompletedEvent(
node_run_result=self._output_adapter.build_success_result(
event=success_event,
edge_source_handle=edge_source_handle,
inputs=inputs,
process_data=process_data,
metadata=metadata,
@@ -815,18 +854,23 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
) -> Mapping[str, Sequence[str]]:
"""Reuse frontend workflow-marker parsing for graph variable loading.
This follows the same marker parser used by publish sync and runtime
request building, including reserved-prefix exclusion.
Task mentions use the publish parser. Enabled route conditions use
Graphon template selectors, including environment and system variables;
disabled jobs do not load their saved conditions.
"""
del graph_config
agent_task = (
node_data.get("agent_task") if isinstance(node_data, Mapping) else getattr(node_data, "agent_task", None)
)
if not isinstance(agent_task, str):
return {}
return {
f"{node_id}.{'.'.join(selector)}": list(selector)
for selector in extract_workflow_node_output_selectors(agent_task)
}
if isinstance(node_data, Mapping):
task = node_data.get("agent_task", "")
routes = WorkflowOutputRoutes.model_validate(node_data.get("agent_output_routes", {}))
else:
task = node_data.agent_task
routes = node_data.agent_output_routes
selectors = list(extract_workflow_node_output_selectors(task))
if routes.enabled:
for route in routes.routes:
selectors.extend(
tuple(selector.value_selector)
for selector in VariableTemplateParser(template=route.name).extract_variable_selectors()
)
return {f"{node_id}.{'.'.join(selector)}": list(selector) for selector in selectors}
+4 -1
View File
@@ -1,14 +1,17 @@
from typing import Literal
from pydantic import model_validator
from pydantic import Field, model_validator
from graphon.entities.base_node_data import BaseNodeData
from graphon.enums import BuiltinNodeTypes, NodeType
from models.agent_config_entities import WorkflowOutputRoutes
class DifyAgentNodeData(BaseNodeData):
type: NodeType = BuiltinNodeTypes.AGENT
agent_node_kind: Literal["dify_agent"]
agent_task: str = ""
agent_output_routes: WorkflowOutputRoutes = Field(default_factory=WorkflowOutputRoutes)
@model_validator(mode="after")
def validate_version(self) -> "DifyAgentNodeData":
@@ -53,13 +53,15 @@ class WorkflowAgentOutputAdapter:
metadata: dict[str, Any],
declared_outputs: Sequence[DeclaredOutputConfig] | None = None,
tenant_id: str | None = None,
edge_source_handle: str = "source",
) -> NodeRunResult:
"""Build the successful node result from one backend terminal event.
For structured runs, ``declared_outputs`` contains the custom-only
declarations used to scope field and file normalization. Plain runs
omit custom declarations, and a free-form string is mapped to the
system ``text`` output.
system ``text`` output. The caller validates and selects the successful
``edge_source_handle`` before invoking this adapter.
Canonical persisted-file mappings (``local_file`` / ``tool_file`` /
``datasource_file``) also require ``metadata["tenant_id"]`` so the
@@ -72,6 +74,7 @@ class WorkflowAgentOutputAdapter:
resolved_tenant_id = tenant_id or (metadata_tenant_id if isinstance(metadata_tenant_id, str) else None)
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
edge_source_handle=edge_source_handle,
inputs=inputs,
process_data=process_data,
outputs=self._normalize_outputs(
@@ -53,6 +53,7 @@ from core.plugin.provider_identity import normalize_plugin_daemon_provider_ident
from core.workflow.system_variables import SystemVariableKey, get_system_text, get_system_value
from graphon.file import File, FileTransferMethod
from graphon.variables.segments import Segment
from graphon.variables.template_resolution import convert_template
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
from models.agent_config_entities import (
AgentKnowledgeMetadataFilteringConfig,
@@ -65,6 +66,7 @@ from models.agent_config_entities import (
DeclaredOutputConfig,
DeclaredOutputType,
WorkflowNodeJobConfig,
WorkflowOutputRoutes,
WorkflowPreviousNodeOutputRef,
)
from models.provider_ids import ModelProviderID
@@ -169,6 +171,7 @@ class WorkflowAgentRuntimeRequestBuilder:
def build(self, context: WorkflowAgentRuntimeBuildContext) -> WorkflowAgentRuntimeRequest:
agent_soul = AgentSoulConfig.model_validate(context.snapshot.config_snapshot_dict)
node_job = WorkflowNodeJobConfig.model_validate(context.binding.node_job_config_dict)
node_job.output_routes.validate_for_execution()
if agent_soul.model is None:
raise WorkflowAgentRuntimeRequestBuildError(
"agent_model_not_configured",
@@ -263,7 +266,9 @@ class WorkflowAgentRuntimeRequestBuilder:
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),
output=self._build_output_config(
node_job.declared_outputs, node_job.output_routes, context.variable_pool
),
tools=tool_layers.plugin_tools,
core_tools=tool_layers.core_tools,
knowledge=knowledge_config,
@@ -516,17 +521,35 @@ class WorkflowAgentRuntimeRequestBuilder:
return None
@staticmethod
def _build_output_config(declared_outputs: Sequence[DeclaredOutputConfig]) -> AgentBackendOutputConfig | None:
def _build_output_config(
declared_outputs: Sequence[DeclaredOutputConfig],
output_routes: WorkflowOutputRoutes | None = None,
variable_pool: VariablePoolReader | None = None,
) -> AgentBackendOutputConfig | None:
"""Build the structured-output layer config sent to Agent backend.
Plain-output jobs omit this layer. Structured jobs prepend the optional,
system-owned ``text`` field to the persisted custom declarations.
Enabled routing adds a required system ``switch``
in the same Agent call. Omit the structured-output layer only when there
are no custom outputs and routing is disabled.
"""
if not declared_outputs:
if not declared_outputs and (output_routes is None or not output_routes.enabled):
return None
properties: dict[str, Any] = {"text": {"type": "string"}}
required: list[str] = []
if output_routes is not None and output_routes.enabled:
if variable_pool is None:
raise ValueError("Output route selection requires a workflow variable pool.")
descriptions = [
f"{route.id} ({route.label or route.id}): {convert_template(variable_pool, route.name).text}"
for route in output_routes.routes
]
properties["switch"] = {
"type": "string",
"enum": [route.id for route in output_routes.routes],
"description": "Select exactly one route ID based on the task result:\n" + "\n".join(descriptions),
}
required.append("switch")
for output in declared_outputs:
properties[output.name] = WorkflowAgentRuntimeRequestBuilder._schema_for_declared_output(output)
if output.required:
+23 -2
View File
@@ -7,8 +7,12 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from core.agent.publish_visibility import workflow_callable_active_snapshot_filter
from core.agent.workflow_references import (
extract_workflow_node_output_selectors,
workflow_previous_node_output_refs_from_selectors,
)
from core.workflow.graph_topology import WorkflowGraphTopology
from graphon.enums import BuiltinNodeTypes
from graphon.enums import BuiltinNodeTypes, ErrorStrategy
from models.agent import (
Agent,
AgentConfigSnapshot,
@@ -115,6 +119,14 @@ class WorkflowAgentNodeValidator:
f"Workflow Agent node {node_id} requires a binding before publishing."
)
continue
if (
require_binding
and node_data.get("error_strategy") == ErrorStrategy.DEFAULT_VALUE
and WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict).output_routes.enabled
):
raise WorkflowAgentNodeValidationError(
"Output routes do not support the node-level default-value error strategy."
)
cls.validate_binding(
session=session,
binding=binding,
@@ -191,6 +203,8 @@ class WorkflowAgentNodeValidator:
cls._validate_agent_soul_tools(binding=binding, agent_soul=agent_soul)
cls._validate_agent_soul_knowledge(session=session, binding=binding, agent_soul=agent_soul)
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
if require_agent_model:
node_job.output_routes.validate_for_execution()
cls.validate_node_job(session=session, binding=binding, node_job=node_job, topology=topology)
@classmethod
@@ -221,7 +235,14 @@ class WorkflowAgentNodeValidator:
ref_context=f"output {output.name} benchmark file",
)
for ref in node_job.previous_node_output_refs:
route_refs = (
workflow_previous_node_output_refs_from_selectors(
extract_workflow_node_output_selectors("\n".join(route.name for route in node_job.output_routes.routes))
)
if node_job.output_routes.enabled
else []
)
for ref in [*node_job.previous_node_output_refs, *route_refs]:
selector = cls.selector_from_ref(ref)
if selector is None:
raise WorkflowAgentNodeValidationError(
+48 -4
View File
@@ -1072,16 +1072,59 @@ SYSTEM_DECLARED_OUTPUTS: Final[tuple[DeclaredOutputConfig, ...]] = (
description="Free-form text answer.",
),
)
# ``switch`` and ``_session`` are reserved for future system output contracts.
# ``switch`` is derived when routing is enabled; ``_session`` remains reserved.
RESERVED_DECLARED_OUTPUT_NAMES: Final[frozenset[str]] = frozenset({"text", "switch", "_session"})
class WorkflowOutputRoute(BaseModel):
"""Stable workflow exit identity and its model-visible selection condition."""
model_config = ConfigDict(extra="forbid")
id: str = Field(min_length=1)
name: str = ""
label: str | None = None
@field_validator("id")
@classmethod
def _validate_id(cls, value: str) -> str:
if not value.strip() or value in {"source", "target", "fail-branch"}:
raise ValueError("output route id must be nonblank and must not use a system handle")
return value
class WorkflowOutputRoutes(BaseModel):
"""Enabled routes require at least two exits; drafts may omit conditions."""
model_config = ConfigDict(extra="forbid")
enabled: bool = False
routes: list[WorkflowOutputRoute] = Field(default_factory=list)
@model_validator(mode="after")
def _validate_routes(self) -> Self:
if self.enabled and len(self.routes) < 2:
raise ValueError("Enabled output routes require at least two routes.")
if len({route.id for route in self.routes}) != len(self.routes):
raise ValueError("output route ids must be unique")
return self
def validate_for_execution(self) -> None:
if self.enabled and any(not route.name.strip() for route in self.routes):
raise ValueError("Each enabled output route requires a selection condition.")
def effective_declared_outputs(
declared_outputs: list[DeclaredOutputConfig] | tuple[DeclaredOutputConfig, ...],
output_routes: WorkflowOutputRoutes | None = None,
) -> tuple[DeclaredOutputConfig, ...]:
"""Project the system ``text`` output followed by custom declarations."""
return SYSTEM_DECLARED_OUTPUTS + tuple(declared_outputs)
"""Project system outputs for this job before its custom declarations."""
switch = (
(DeclaredOutputConfig(name="switch", type=DeclaredOutputType.STRING, description="Selected output route ID."),)
if output_routes is not None and output_routes.enabled
else ()
)
return SYSTEM_DECLARED_OUTPUTS + switch + tuple(declared_outputs)
class WorkflowNodeJobConfig(BaseModel):
@@ -1092,6 +1135,7 @@ class WorkflowNodeJobConfig(BaseModel):
workflow_prompt: str = ""
previous_node_output_refs: list[WorkflowPreviousNodeOutputRef] = Field(default_factory=list)
declared_outputs: list[DeclaredOutputConfig] = Field(default_factory=list)
output_routes: WorkflowOutputRoutes = Field(default_factory=WorkflowOutputRoutes)
human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list)
metadata: WorkflowNodeJobMetadata = Field(default_factory=WorkflowNodeJobMetadata)
+20
View File
@@ -24770,6 +24770,7 @@ tenant's default model. The underlying generator never raises — an empty
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
| metadata | [WorkflowNodeJobMetadata](#workflownodejobmetadata) | | No |
| mode | [WorkflowNodeJobMode](#workflownodejobmode) | | No |
| output_routes | [WorkflowOutputRoutes](#workflowoutputroutes) | | No |
| previous_node_output_refs | [ [WorkflowPreviousNodeOutputRef](#workflowpreviousnodeoutputref) ] | | No |
| schema_version | integer, <br>**Default:** 1 | | No |
| workflow_prompt | string | | No |
@@ -24814,6 +24815,25 @@ tenant's default model. The underlying generator never raises — an empty
| ---- | ---- | ----------- | -------- |
| data | [ [WorkflowOnlineUsersByApp](#workflowonlineusersbyapp) ] | | Yes |
#### WorkflowOutputRoute
Stable workflow exit identity and its model-visible selection condition.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| id | string | | Yes |
| label | string | | No |
| name | string | | No |
#### WorkflowOutputRoutes
Enabled routes require at least two exits; drafts may omit conditions.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| enabled | boolean | | No |
| routes | [ [WorkflowOutputRoute](#workflowoutputroute) ] | | No |
#### WorkflowPaginationResponse
| Name | Type | Description | Required |
+17 -23
View File
@@ -1263,7 +1263,7 @@ class AgentComposerService:
node_job = cls._parse_node_job(binding)
if node_job is None:
return None
return list(_effective_declared_outputs(node_job.declared_outputs))
return list(_effective_declared_outputs(node_job.declared_outputs, node_job.output_routes))
@staticmethod
def _draft_node_variables(*, session: Any, app_id: str, node_id: str, user_id: str) -> list[tuple[str, str | None]]:
@@ -1376,7 +1376,6 @@ class AgentComposerService:
binding: WorkflowAgentNodeBinding | None,
payload: ComposerSavePayload,
) -> WorkflowAgentNodeBinding:
node_job = payload.node_job or WorkflowNodeJobConfig()
if binding:
if cls._is_start_from_scratch_request(binding=binding, payload=payload):
return cls._switch_roster_binding_to_inline_agent(
@@ -1389,7 +1388,8 @@ class AgentComposerService:
binding=binding,
payload=payload,
)
binding.node_job_config = node_job
if payload.node_job is not None:
binding.node_job_config = payload.node_job
if payload.agent_soul is not None and binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT:
current_snapshot = cls._require_version(
session=session,
@@ -1449,7 +1449,7 @@ class AgentComposerService:
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
agent_id=agent.id,
current_snapshot_id=agent.active_config_snapshot_id,
node_job_config=node_job,
node_job_config=payload.node_job or WorkflowNodeJobConfig(),
created_by=account_id,
updated_by=account_id,
)
@@ -1630,7 +1630,6 @@ class AgentComposerService:
target_snapshot_id=agent.active_config_snapshot_id,
user_id=account_id,
)
node_job = payload.node_job or WorkflowNodeJobConfig()
if not binding:
binding = WorkflowAgentNodeBinding(
tenant_id=tenant_id,
@@ -1638,13 +1637,15 @@ class AgentComposerService:
workflow_id=workflow_id,
workflow_version=_DRAFT_WORKFLOW_VERSION,
node_id=node_id,
node_job_config=WorkflowNodeJobConfig(),
created_by=account_id,
)
session.add(binding)
binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT
binding.agent_id = agent.id
binding.current_snapshot_id = agent.active_config_snapshot_id
binding.node_job_config = node_job
if payload.node_job is not None:
binding.node_job_config = payload.node_job
binding.updated_by = account_id
session.flush()
return binding
@@ -2222,20 +2223,12 @@ class AgentComposerService:
)
@staticmethod
def _declared_outputs_from_binding(binding: WorkflowAgentNodeBinding) -> list[DeclaredOutputConfig]:
"""Re-hydrate the binding's custom-only persisted output declarations.
node_job_config is stored as JSON / LongText; the typed view is needed
before the later effective-output projection prepends the system
``text`` output.
"""
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
return list(node_job.declared_outputs)
@staticmethod
def _serialize_effective_outputs(declared_outputs: list[DeclaredOutputConfig]) -> list[dict[str, Any]]:
"""JSON-serialize system ``text`` followed by custom declarations."""
return [output.model_dump(mode="json") for output in _effective_declared_outputs(declared_outputs)]
def _serialize_effective_outputs(node_job: WorkflowNodeJobConfig) -> list[dict[str, Any]]:
"""Serialize custom declarations and the system outputs enabled for this job."""
return [
output.model_dump(mode="json")
for output in _effective_declared_outputs(node_job.declared_outputs, node_job.output_routes)
]
@classmethod
def _empty_workflow_state(cls, *, app_id: str, workflow_id: str, node_id: str) -> dict[str, Any]:
@@ -2248,7 +2241,7 @@ class AgentComposerService:
"agent_soul": AgentSoulConfig().model_dump(mode="json"),
"node_job": WorkflowNodeJobConfig().model_dump(mode="json"),
# ``text`` is derived for the editor and is not stored in node_job.
"effective_declared_outputs": cls._serialize_effective_outputs([]),
"effective_declared_outputs": cls._serialize_effective_outputs(WorkflowNodeJobConfig()),
"save_options": [ComposerSaveStrategy.NODE_JOB_ONLY.value, ComposerSaveStrategy.SAVE_TO_ROSTER.value],
"impact_summary": None,
"app_id": app_id,
@@ -2313,8 +2306,9 @@ class AgentComposerService:
if version
else AgentSoulConfig().model_dump(mode="json"),
"node_job": binding.node_job_config_dict,
# Surface system ``text`` followed by the binding's custom outputs.
"effective_declared_outputs": cls._serialize_effective_outputs(cls._declared_outputs_from_binding(binding)),
"effective_declared_outputs": cls._serialize_effective_outputs(
WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
),
"save_options": save_options,
"impact_summary": cls.calculate_impact(
session=session, tenant_id=binding.tenant_id, current_snapshot_id=version.id
+12 -51
View File
@@ -13,10 +13,11 @@ Workflow Agent tasks also carry frontend workflow variable markers:
{{#<node-id>.<output>[.<child>...]#}}
Those frontend markers are a separate path from slash-reference expansion. They
are parsed here only to derive ``previous_node_output_refs`` from the current
task text. The markers remain literal in the workflow task prompt, while their
resolved values appear under the workflow context prompt's ``Previous node
outputs:`` section. Legacy ``[§node_output:...§]`` mention syntax is not part
are parsed by ``core.agent.workflow_references``, re-exported here to derive
``previous_node_output_refs`` from the current task text. The markers remain
literal in the workflow task prompt, while their resolved values appear under
the workflow context prompt's ``Previous node outputs:`` section. Legacy
``[§node_output:...§]`` mention syntax is not part
of that derivation path.
Frontend output blocks still accept a legacy bare ``§output:...§`` form during
@@ -30,6 +31,12 @@ from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from core.agent.workflow_references import (
WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES,
extract_workflow_node_output_selectors,
extract_workflow_variable_selectors,
workflow_previous_node_output_refs_from_selectors,
)
from models.agent_config_entities import (
AgentHumanContactConfig,
AgentSoulConfig,
@@ -64,7 +71,6 @@ MENTION_PATTERN = re.compile(
# 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_]*:[^§]*?)§\]")
WORKFLOW_VARIABLE_PATTERN = re.compile(r"\{\{#([^{}#]+?\.[^{}#]+?)#\}\}")
MAX_MENTIONS_PER_PROMPT = 200
# Mention ids are bounded independently of their owning configuration schema.
@@ -93,9 +99,6 @@ SOUL_PROMPT_ALLOWED_KINDS = frozenset(
}
)
NODE_JOB_PROMPT_ALLOWED_KINDS = frozenset({MentionKind.NODE_OUTPUT, MentionKind.OUTPUT, MentionKind.HUMAN})
WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES = frozenset(
{"sys", "env", "conversation", "rag", "current", "last_run", "error_message", "$output"}
)
@dataclass(frozen=True, slots=True)
@@ -182,48 +185,6 @@ def find_malformed_mention_markers(prompt: str) -> list[str]:
return [match.group(0) for match in _RESIDUAL_MENTION_PATTERN.finditer(prompt) if match.span() not in parsed_spans]
def extract_workflow_variable_selectors(prompt: str) -> list[tuple[str, ...]]:
"""Extract ``{{#node.output#}}``-style selectors from workflow prompts."""
selectors: list[tuple[str, ...]] = []
for match in WORKFLOW_VARIABLE_PATTERN.finditer(prompt or ""):
parts = tuple(part.strip() for part in match.group(1).split(".") if part.strip())
if len(parts) >= 2:
selectors.append(parts)
return selectors
def extract_workflow_node_output_selectors(prompt: str) -> list[tuple[str, ...]]:
"""Extract previous-node selectors from frontend workflow variable markers.
Reserved Dify namespaces such as ``sys`` are excluded because they are not
previous nodes.
"""
selectors: list[tuple[str, ...]] = []
seen: set[tuple[str, ...]] = set()
for selector in extract_workflow_variable_selectors(prompt):
if selector[0] in WORKFLOW_NODE_OUTPUT_RESERVED_PREFIXES:
continue
if selector in seen:
continue
selectors.append(selector)
seen.add(selector)
return selectors
def workflow_previous_node_output_refs_from_selectors(
selectors: list[tuple[str, ...]],
) -> list[WorkflowPreviousNodeOutputRef]:
"""Materialize persisted previous-node refs from parsed frontend selectors."""
return [
WorkflowPreviousNodeOutputRef(
selector=list(selector),
node_id=selector[0],
output=selector[1],
)
for selector in selectors
]
def scrub_mention_markers(text: str) -> str:
"""Degrade any residual mention-shaped ``[§kind:…§]`` marker to readable text."""
@@ -301,7 +262,7 @@ def build_node_job_mention_resolver(node_job: WorkflowNodeJobConfig) -> MentionR
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 effective_declared_outputs(node_job.declared_outputs):
for output in effective_declared_outputs(node_job.declared_outputs, node_job.output_routes):
if output.name == mention.ref_id:
return _format_output_mention(output)
case MentionKind.HUMAN:
@@ -21,6 +21,7 @@ from models.agent import (
from models.agent_config_entities import (
AgentSoulConfig,
WorkflowNodeJobConfig,
WorkflowOutputRoutes,
WorkflowPreviousNodeOutputRef,
)
from models.workflow import Workflow
@@ -56,6 +57,7 @@ class WorkflowAgentPublishService:
_AGENT_BINDING_KEY = "agent_binding"
_AGENT_TASK_KEY = "agent_task"
_AGENT_DECLARED_OUTPUTS_KEY = "agent_declared_outputs"
_AGENT_OUTPUT_ROUTES_KEY = "agent_output_routes"
@classmethod
def project_draft_bindings_to_graph(cls, *, session: Session, draft_workflow: Workflow) -> dict[str, Any]:
@@ -99,6 +101,7 @@ class WorkflowAgentPublishService:
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
if node_job.workflow_prompt is not None:
node_data[cls._AGENT_TASK_KEY] = node_job.workflow_prompt
node_data[cls._AGENT_OUTPUT_ROUTES_KEY] = node_job.output_routes.model_dump(mode="json")
node_data[cls._AGENT_DECLARED_OUTPUTS_KEY] = [
output.model_dump(mode="json") for output in node_job.declared_outputs
]
@@ -583,6 +586,7 @@ class WorkflowAgentPublishService:
except ValidationError as exc:
raise ValueError("Workflow Agent node has invalid agent_declared_outputs.") from exc
node_job.output_routes = WorkflowOutputRoutes.model_validate(node_data.get(cls._AGENT_OUTPUT_ROUTES_KEY, {}))
return node_job
@classmethod
@@ -780,7 +780,7 @@ class NodeOutputInspectorService:
"NodeOutputInspector: malformed node_job_config for binding %s", bundle.binding.id, exc_info=True
)
return None
return list(effective_declared_outputs(node_job.declared_outputs))
return list(effective_declared_outputs(node_job.declared_outputs, node_job.output_routes))
@staticmethod
def _infer_outputs_from_payload(*, execution: WorkflowNodeExecutionModel | None) -> list[_ResolvedDeclaration]:
@@ -52,7 +52,13 @@ from core.workflow.nodes.agent_v2.session_store import (
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
from graphon.entities import GraphInitParams
from graphon.entities.pause_reason import HitlRequired
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
from graphon.enums import (
BuiltinNodeTypes,
ErrorStrategy,
NodeExecutionType,
WorkflowNodeExecutionMetadataKey,
WorkflowNodeExecutionStatus,
)
from graphon.file import File, FileTransferMethod, FileType
from graphon.graph_events import NodeRunPauseRequestedEvent
from graphon.node_events import StreamCompletedEvent
@@ -404,6 +410,7 @@ def _node(
agent_backend_client: FakeAgentBackendRunClient | None = None,
binding_resolver: FakeBindingResolver | None = None,
runtime_request_builder: WorkflowAgentRuntimeRequestBuilder | None = None,
error_strategy: ErrorStrategy | None = None,
) -> DifyAgentNode:
graph_init_params = GraphInitParams(
workflow_id="workflow-1",
@@ -440,7 +447,16 @@ def _node(
node = DifyAgentNode(
node_id="agent-node",
data=DifyAgentNodeData.model_validate(
{"type": BuiltinNodeTypes.AGENT, "version": "2", "agent_node_kind": "dify_agent"}
{
"type": BuiltinNodeTypes.AGENT,
"version": "2",
"agent_node_kind": "dify_agent",
"agent_output_routes": binding_resolver.binding.node_job_config.output_routes.model_dump(),
"error_strategy": error_strategy,
"default_value": [{"key": "text", "type": "string", "value": "fallback"}]
if error_strategy == ErrorStrategy.DEFAULT_VALUE
else [],
}
),
graph_init_params=graph_init_params,
graph_runtime_state=cast(
@@ -1114,3 +1130,224 @@ def test_agent_node_records_stream_usage_metadata():
assert agent_backend["last_stream_event_id"] == "1-1"
assert agent_backend["last_stream_event_kind"] == "model_response"
assert agent_backend["usage"] == {"prompt_tokens": 3, "completion_tokens": 4, "total_tokens": 7}
@pytest.mark.parametrize(
("enabled", "count", "output", "expected_handle"),
[
(False, 2, "hello", "source"),
(True, 2, {"text": "hello", "switch": "route-1"}, "route-1"),
],
)
def test_agent_node_selects_the_configured_success_exit(enabled, count, output, expected_handle):
resolver = FakeBindingResolver()
resolver.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
{
"output_routes": {
"enabled": enabled,
"routes": [{"id": f"route-{i}", "name": "Condition"} for i in range(count)],
}
}
)
node = _node(binding_resolver=resolver, agent_backend_client=FileOutputBackendClient(output_payload=output))
result = list(node._run())[-1].node_run_result
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.edge_source_handle == expected_handle
assert result.outputs == (output if isinstance(output, dict) else {"text": output})
@pytest.mark.parametrize(
("enabled", "error_strategy", "expected_execution_type"),
[
(False, None, NodeExecutionType.EXECUTABLE),
(True, None, NodeExecutionType.BRANCH),
(False, ErrorStrategy.DEFAULT_VALUE, NodeExecutionType.EXECUTABLE),
(True, ErrorStrategy.FAIL_BRANCH, NodeExecutionType.BRANCH),
(True, ErrorStrategy.DEFAULT_VALUE, None),
],
)
def test_agent_routes_initialize_through_workflow_node_factory(
enabled, error_strategy, expected_execution_type, config_overrides
):
from core.workflow.node_factory import DifyNodeFactory
config_overrides(AGENT_BACKEND_USE_FAKE=True)
template = _node()
factory = DifyNodeFactory(template.graph_init_params, template.graph_runtime_state)
node_data = template.node_data.model_dump(mode="python", by_alias=True)
node_data.update(
agent_output_routes={
"enabled": enabled,
"routes": [{"id": "a", "name": "A"}, {"id": "b", "name": "B"}],
},
error_strategy=error_strategy,
)
# Exercise the production factory, which serializes data before construction.
node_config = {"id": "agent-node", "data": node_data}
if expected_execution_type is None:
with pytest.raises(ValueError, match="default-value"):
factory.create_node(node_config)
return
node = factory.create_node(node_config)
assert isinstance(node, DifyAgentNode)
assert node.execution_type == expected_execution_type
assert node.node_data.agent_output_routes.enabled is enabled
def test_agent_routes_use_the_successful_retry_to_fan_out_and_skip_other_branches():
from graphon.enums import NodeState
from graphon.graph.edge import Edge
from graphon.graph.graph import Graph
from graphon.graph_engine.graph_state_manager import GraphStateManager
from graphon.graph_engine.graph_traversal.edge_processor import EdgeProcessor
from graphon.graph_engine.graph_traversal.skip_propagator import SkipPropagator
from graphon.graph_events.traversal import GraphEdgeSkippedEvent, GraphEdgeTakenEvent
from graphon.nodes.end.end_node import EndNode
from graphon.nodes.end.entities import EndNodeData
outputs = iter([{"score": "invalid", "switch": "a"}, {"score": 42, "switch": "b"}])
class RetryingBackendClient(FileOutputBackendClient):
def _events(self, run_id: str):
self.output_payload = next(outputs)
return super()._events(run_id)
resolver = FakeBindingResolver()
resolver.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
{
"declared_outputs": [
{
"name": "score",
"type": "number",
"failure_strategy": {"retry": {"enabled": True, "max_retries": 1}},
}
],
"output_routes": {
"enabled": True,
"routes": [{"id": "a", "name": "A"}, {"id": "b", "name": "B"}],
},
}
)
node = _node(binding_resolver=resolver, agent_backend_client=RetryingBackendClient(output_payload={}))
result = list(node._run())[-1].node_run_result
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.outputs["score"] == 42
edges = {
target: Edge(id=target, tail="agent-node", head=target, source_handle=handle)
for target, handle in [("a-end", "a"), ("b-end-1", "b"), ("b-end-2", "b")]
}
leaves = {
target: EndNode(
node_id=target,
data=EndNodeData(title=target, outputs=[]),
graph_init_params=node.graph_init_params,
graph_runtime_state=node.graph_runtime_state,
)
for target in edges
}
graph = Graph(
root_node=node,
nodes={"agent-node": node, **leaves},
edges=edges,
out_edges={"agent-node": list(edges)},
in_edges={target: [target] for target in edges},
)
state = GraphStateManager(graph, node.graph_runtime_state, "root")
ready, traversed = EdgeProcessor(graph, state, SkipPropagator(graph, state)).process_node_success(
"agent-node", result.edge_source_handle
)
assert ready == ["b-end-1", "b-end-2"]
assert [event.edge_id for event in traversed if isinstance(event, GraphEdgeTakenEvent)] == ["b-end-1", "b-end-2"]
assert [event.edge_id for event in traversed if isinstance(event, GraphEdgeSkippedEvent)] == ["a-end"]
assert leaves["a-end"].state == NodeState.SKIPPED
@pytest.mark.parametrize("output", ["plain text", {}, {"switch": None}, {"switch": 1}, {"switch": "unknown"}])
def test_agent_node_rejects_invalid_route_selection_without_custom_outputs(output):
resolver = FakeBindingResolver()
resolver.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
{
"output_routes": {
"enabled": True,
"routes": [{"id": "a", "name": "A"}, {"id": "b", "name": "B"}],
}
}
)
node = _node(binding_resolver=resolver, agent_backend_client=FileOutputBackendClient(output_payload=output))
result = list(node._run())[-1].node_run_result
assert result.status == WorkflowNodeExecutionStatus.FAILED
assert result.error_type == "output_route_selection_failed"
@pytest.mark.parametrize(
("enabled", "count", "expected"),
[
(False, 2, {}),
(
True,
2,
{
"agent-node.previous.report": ["previous", "report"],
"agent-node.env.threshold": ["env", "threshold"],
},
),
],
)
def test_agent_route_conditions_load_variables_only_when_routing_is_enabled(enabled, count, expected):
mapping = DifyAgentNode._extract_variable_selector_to_variable_mapping(
graph_config={},
node_id="agent-node",
node_data={
"agent_output_routes": {
"enabled": enabled,
"routes": [{"id": str(i), "name": "{{#previous.report#}} {{#env.threshold#}}"} for i in range(count)],
}
},
)
assert mapping == expected
def test_disabled_routes_continue_through_graphon_default_values():
from graphon.graph.edge import Edge
from graphon.graph.graph import Graph
from graphon.graph_engine.error_handler import ErrorHandler
from graphon.graph_engine.graph_traversal.edge_processor import EdgeProcessor
from graphon.graph_events.node import NodeRunExceptionEvent, NodeRunFailedEvent
from graphon.node_events import NodeRunResult
node = _node(error_strategy=ErrorStrategy.DEFAULT_VALUE)
edges = {target: Edge(id=target, tail="agent-node", head=target) for target in ["next-a", "next-b"]}
graph = Graph(root_node=node, nodes={"agent-node": node}, edges=edges, out_edges={"agent-node": list(edges)})
failure = NodeRunFailedEvent(
id="execution",
node_id="agent-node",
node_type=BuiltinNodeTypes.AGENT,
start_at=datetime.now(UTC),
finished_at=datetime.now(UTC),
error="backend failed",
node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.FAILED, error="backend failed"),
)
result = ErrorHandler(graph, MagicMock()).handle_node_failure(frame_id="root", event=failure)
assert isinstance(result, NodeRunExceptionEvent)
assert result.node_run_result.outputs["text"] == "fallback"
ready, traversed = EdgeProcessor(graph, MagicMock(), MagicMock()).process_node_success(
"agent-node", result.node_run_result.edge_source_handle
)
assert ready == ["next-a", "next-b"]
assert [event.edge_id for event in traversed] == ["next-a", "next-b"]
def test_enabled_routes_reject_default_value_before_execution():
resolver = FakeBindingResolver()
resolver.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
{
"output_routes": {
"enabled": True,
"routes": [{"id": "accepted", "name": "Accept"}, {"id": "rejected", "name": "Reject"}],
}
}
)
with pytest.raises(ValueError, match="default-value"):
_node(binding_resolver=resolver, error_strategy=ErrorStrategy.DEFAULT_VALUE)
@@ -1701,3 +1701,27 @@ def test_feature_manifest_treats_empty_knowledge_sets_as_not_configured():
manifest = build_runtime_feature_manifest(soul)
assert manifest["reserved_status"]["knowledge"] == "not_configured"
@pytest.mark.parametrize("custom", [False, True])
def test_runtime_request_adds_required_route_selection_with_resolved_conditions(custom):
context = _context()
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
{
"declared_outputs": [{"name": "summary", "type": "string"}] if custom else [],
"output_routes": {
"enabled": True,
"routes": [
{"id": "accept", "name": "Matches {{#sys.query#}}", "label": "Accepted"},
{"id": "reject", "name": "Does not match"},
],
},
}
)
result = WorkflowAgentRuntimeRequestBuilder(dify_tools_builder=CapturingPluginLayerBuilder()).build(context)
schema = _request_layers(result)[DIFY_AGENT_OUTPUT_LAYER_ID]["config"]["json_schema"]
assert schema["properties"]["switch"]["enum"] == ["accept", "reject"]
assert "Accepted" in schema["properties"]["switch"]["description"]
assert "Matches Summarize the report." in schema["properties"]["switch"]["description"]
assert schema["required"] == (["switch", "summary"] if custom else ["switch"])
assert schema["properties"]["text"] == {"type": "string"}
@@ -734,3 +734,56 @@ def test_publish_validation_rejects_unauthorized_tool_node_agentic_config(unboun
session=unbound_session,
workflow=_workflow(_tool_graph({"agentic_mode": {"state": "agentic", "permission": {"allowed": False}}})),
)
def test_incomplete_route_conditions_can_be_saved_but_not_published(sqlite_session: Session):
routes = [{"id": "a", "name": ""}, {"id": "b", "name": ""}]
job = WorkflowNodeJobConfig.model_validate({"output_routes": {"enabled": True, "routes": routes}})
_persist_validation_scope(sqlite_session, node_job=job)
workflow = _workflow(_graph([{"source": "start", "target": "agent-node"}]))
WorkflowAgentNodeValidator.validate_draft_workflow(session=sqlite_session, workflow=workflow)
with pytest.raises(ValueError, match="route"):
WorkflowAgentNodeValidator.validate_published_workflow(session=sqlite_session, workflow=workflow)
def test_route_conditions_must_reference_upstream_nodes(sqlite_session: Session):
job = WorkflowNodeJobConfig.model_validate(
{
"output_routes": {
"enabled": True,
"routes": [
{"id": "a", "name": "{{#later-node.text#}}"},
{"id": "b", "name": "Otherwise"},
],
}
}
)
_persist_validation_scope(sqlite_session, node_job=job)
with pytest.raises(WorkflowAgentNodeValidationError, match="non-upstream"):
WorkflowAgentNodeValidator.validate_published_workflow(
session=sqlite_session,
workflow=_workflow(
_graph(
[
{"source": "start", "target": "agent-node"},
{"source": "agent-node", "target": "later-node"},
]
)
),
)
def test_publishing_routes_rejects_node_level_default_values(sqlite_session: Session):
node_job = WorkflowNodeJobConfig.model_validate(
{
"output_routes": {
"enabled": True,
"routes": [{"id": "accepted", "name": "Accept"}, {"id": "rejected", "name": "Reject"}],
}
}
)
_persist_validation_scope(sqlite_session, node_job=node_job)
graph = _graph([{"source": "start", "target": "agent-node"}])
graph["nodes"][2]["data"]["error_strategy"] = "default-value"
with pytest.raises(WorkflowAgentNodeValidationError, match="default-value"):
WorkflowAgentNodeValidator.validate_published_workflow(session=sqlite_session, workflow=_workflow(graph))
@@ -246,3 +246,33 @@ def test_effective_declared_outputs_prepends_optional_system_text() -> None:
assert [output.name for output in effective] == ["text", "summary"]
assert effective[0].type == DeclaredOutputType.STRING
assert effective[0].required is False
@pytest.mark.parametrize(
("enabled", "count", "expected"),
[(False, 0, ["text"]), (False, 1, ["text"]), (False, 2, ["text"]), (True, 2, ["text", "switch"])],
)
def test_effective_outputs_follow_route_selection_contract(enabled: bool, count: int, expected: list[str]) -> None:
job = WorkflowNodeJobConfig.model_validate(
{
"output_routes": {
"enabled": enabled,
"routes": [{"id": str(index), "name": "condition"} for index in range(count)],
}
}
)
assert [output.name for output in effective_declared_outputs([], job.output_routes)] == expected
@pytest.mark.parametrize("count", [0, 1])
def test_enabled_output_routes_require_at_least_two_routes(count: int) -> None:
with pytest.raises(ValueError, match="at least two routes"):
WorkflowNodeJobConfig.model_validate(
{"output_routes": {"enabled": True, "routes": [{"id": str(index)} for index in range(count)]}}
)
@pytest.mark.parametrize("ids", [[""], ["source"], ["fail-branch"], ["target"], ["same", "same"]])
def test_output_routes_reject_invalid_handle_identities(ids: list[str]) -> None:
with pytest.raises(ValueError):
WorkflowNodeJobConfig.model_validate({"output_routes": {"routes": [{"id": id, "name": ""} for id in ids]}})
@@ -2650,7 +2650,18 @@ def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
agent_id="inline-agent-1",
current_snapshot_id="inline-version-1",
node_job_config=WorkflowNodeJobConfig.model_validate(
{
"workflow_prompt": "use prior output",
"declared_outputs": [{"name": "summary", "type": "string"}],
"output_routes": {
"enabled": True,
"routes": [{"id": "accepted", "name": "Accept"}, {"id": "rejected", "name": "Reject"}],
},
}
),
)
original_node_job = binding.node_job_config_dict
payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.WORKFLOW.value,
@@ -2663,7 +2674,6 @@ def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch
},
"prompt": {"system_prompt": "new"},
},
"node_job": {"workflow_prompt": "use prior output"},
}
)
@@ -2679,7 +2689,7 @@ def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch
)
assert updated_binding.current_snapshot_id == "inline-version-2"
assert updated_binding.node_job_config_dict["workflow_prompt"] == "use prior output"
assert updated_binding.node_job_config_dict == original_node_job
assert updated_binding.updated_by == "account-1"
assert inline_agent.active_config_snapshot_id == "inline-version-2"
assert inline_agent.active_config_has_model is True
@@ -6993,3 +7003,40 @@ def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(
AgentComposerService.resolve_workflow_node_agent_id(session=session, tenant_id="t", app_id="a", node_id="n")
== "agent-7"
)
def test_save_as_new_agent_preserves_omitted_node_job(monkeypatch: pytest.MonkeyPatch):
job = WorkflowNodeJobConfig.model_validate(
{
"workflow_prompt": "Keep this task",
"output_routes": {
"enabled": True,
"routes": [{"id": "accepted", "name": "Accept"}, {"id": "rejected", "name": "Reject"}],
},
}
)
binding = WorkflowAgentNodeBinding(agent_id="old-agent", current_snapshot_id="old-snapshot", node_job_config=job)
monkeypatch.setattr(
AgentComposerService,
"_create_roster_agent_for_composer",
lambda **kwargs: Agent(id="new-agent", active_config_snapshot_id="new-snapshot"),
)
monkeypatch.setattr(
"services.agent.composer_service.SkillManagementService.copy_agent_bindings", lambda self, **kwargs: None
)
result = AgentComposerService._save_as_new_agent(
session=MagicMock(spec=Session),
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
node_id="node-1",
account_id="account-1",
binding=binding,
payload=ComposerSavePayload(
variant=ComposerVariant.WORKFLOW,
save_strategy=ComposerSaveStrategy.SAVE_AS_NEW_AGENT,
agent_soul=AgentSoulConfig(),
),
)
assert result.agent_id == "new-agent"
assert result.node_job_config_dict == job.model_dump(mode="json")
@@ -524,3 +524,56 @@ def test_restore_clones_inline_binding_owned_by_published_workflow(
assert restored is not None
assert restored.agent_id == "draft-agent"
assert restored.current_snapshot_id == "draft-snapshot"
def test_output_routes_round_trip_through_sync_projection_and_publication(sqlite_session: Session):
import json
from models.agent_config_entities import WorkflowNodeJobConfig
agent = _inline_agent(agent_id="route-agent", workflow_id="workflow-1", node_id="route-node")
snapshot = _snapshot(snapshot_id="route-snapshot", agent_id=agent.id)
sqlite_session.add_all([agent, snapshot])
sqlite_session.flush()
routes = {
"enabled": True,
"routes": [
{"id": "accept", "name": "Accepted", "label": "Continue"},
{"id": "reject", "name": "Rejected", "label": None},
],
}
node_data = {
"type": "agent",
"version": "2",
"agent_node_kind": "dify_agent",
"agent_output_routes": routes,
"agent_binding": {"binding_type": "inline_agent", "agent_id": agent.id, "current_snapshot_id": snapshot.id},
}
workflow = _workflow()
workflow.graph = json.dumps({"nodes": [{"id": "route-node", "data": node_data}], "edges": []})
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=sqlite_session, draft_workflow=workflow, account_id="account-1"
)
projected = WorkflowAgentPublishService.project_draft_bindings_to_graph(
session=sqlite_session, draft_workflow=workflow
)
assert projected["nodes"][0]["data"]["agent_output_routes"] == routes
published = _workflow(workflow_id="published-routes", version="published-routes")
WorkflowAgentPublishService.copy_agent_node_bindings_to_published(
session=sqlite_session, draft_workflow=workflow, published_workflow=published
)
sqlite_session.flush()
frozen = sqlite_session.scalar(
select(WorkflowAgentNodeBinding).where(WorkflowAgentNodeBinding.workflow_id == published.id)
)
assert WorkflowNodeJobConfig.model_validate(frozen.node_job_config_dict).output_routes.model_dump() == routes
node_data["agent_output_routes"] = {"enabled": False, "routes": routes["routes"]}
workflow.graph = json.dumps({"nodes": [{"id": "route-node", "data": node_data}], "edges": []})
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=sqlite_session, draft_workflow=workflow, account_id="account-1"
)
projected = WorkflowAgentPublishService.project_draft_bindings_to_graph(
session=sqlite_session, draft_workflow=workflow
)
assert projected["nodes"][0]["data"]["agent_output_routes"]["enabled"] is False
assert WorkflowNodeJobConfig.model_validate(frozen.node_job_config_dict).output_routes.model_dump() == routes
+4
View File
@@ -4,6 +4,10 @@
E2E_BASE_URL=http://127.0.0.1:3000
E2E_API_URL=http://127.0.0.1:5001
# Optional: an existing E2E workflow in the Cloud template catalog for
# @cloud-catalog-runtime. See e2e/AGENTS.md for the required published graph.
# E2E_AGENT_OUTPUT_ROUTES_PREVIEW_APP_ID=
E2E_STABLE_MODEL_PROVIDER=openai
E2E_STABLE_MODEL_NAME=gpt-5-nano
E2E_STABLE_MODEL_TYPE=llm
+1
View File
@@ -45,6 +45,7 @@ An uninitialized instance is installed and authenticated lazily; an initialized
- `@external-model` and `@external-tool` identify scenarios that call real external runtimes. Deterministic commands exclude these tags; external commands are opt-in.
- `@microphone` uses the checked-in fake audio fixture and an isolated Chromium context.
- `@browser-smoke` runs focused keyboard and navigation coverage in Chromium and WebKit CI lanes.
- `@cloud-catalog-runtime` requires an initialized Cloud runtime and an existing E2E workflow in its template catalog. It is excluded from default functional runs. Set `E2E_AGENT_OUTPUT_ROUTES_PREVIEW_APP_ID` to a published workflow with labeled Agent output routes, connections for every route, and a connected failure branch, then run `pnpm -C e2e e2e -- --tags @cloud-catalog-runtime`. Do not use `--full`: Cloud disables local setup. The scenario validates the fixture through the generated catalog and trial-app APIs and never modifies or deletes it. Catalog administrators own fixture preparation; for a local database catalog, prepare and publish the app under `COMMUNITY` before switching the API to `CLOUD` with `HOSTED_FETCH_APP_TEMPLATES_MODE=db`.
- `@skip` temporarily excludes a scenario from every runner profile. Remove it as soon as the covered product behavior is available again; do not use it for permanent or environment-dependent suppression.
- Feature-owned services use their own tags. Agent v2 runtime scenarios use `@agent-backend-runtime` and require the explicit runtime-availability step. Set `E2E_START_AGENT_BACKEND=1` to start it locally, or provide `E2E_AGENT_BACKEND_URL` / `AGENT_BACKEND_BASE_URL`.
+1 -1
View File
@@ -3,7 +3,7 @@ import './scripts/env-register.ts'
const hasCliTags = process.argv.some((arg) => arg === '--tags' || arg.startsWith('--tags='))
const defaultNonExternalTags =
'not @axe and not @prepared and not @external-model and not @external-tool'
'not @axe and not @prepared and not @external-model and not @external-tool and not @cloud-catalog-runtime'
const selectedTags =
process.env.E2E_CUCUMBER_TAGS || (hasCliTags ? undefined : defaultNonExternalTags)
const tags = selectedTags ? `(${selectedTags}) and not @skip` : 'not @skip'
@@ -0,0 +1,33 @@
@agent-v2 @authenticated @core @output-routes
Feature: Agent v2 output routes across workflow surfaces
@output-route-sort
Scenario: Dragging output routes preserves their saved order
Given I am signed in as the default E2E admin
And a workflow with two Agent v2 output routes and a failure branch has been created via API
When I open the app from the app list
And I open the Agent v2 workflow node panel
And I drag the Rejected Agent output route above Accepted
Then the Agent output route order should be saved as Rejected then Accepted
When I refresh the current page
And I open the Agent v2 workflow node panel
Then the Agent output route editor should show Rejected before Accepted
@output-route-snippet
Scenario: Inserting a routed Agent snippet leaves its route destinations for the user to choose
Given I am signed in as the default E2E admin
And a published snippet ending in an Agent v2 with output routes has been created via API
And a "workflow" app has been created via API
And a minimal runnable workflow draft has been synced
When I open the app from the app list
And I insert the routed Agent snippet between Start and End
Then the inserted Agent should receive the Start connection and leave its output routes unconnected
@output-route-undo
Scenario: One undo restores output routes and their connections after disabling them
Given I am signed in as the default E2E admin
And a workflow with two Agent v2 output routes and a failure branch has been created via API
When I open the app from the app list
And I open the Agent v2 workflow node panel
And I disable the Agent output routes and undo the saved change once
And I open the Agent v2 workflow node panel
Then the Agent output routes and both successful connections should be restored
@@ -0,0 +1,81 @@
import type { SyncDraftWorkflowPayload } from '@dify/contracts/api/console/apps/types.gen'
export const agentOutputRoutes = [
{ id: 'accepted', label: 'Accepted', name: 'The request is accepted.' },
{ id: 'rejected', label: 'Rejected', name: 'The request is rejected.' },
]
export function createRoutedAgentNode(agentId: string) {
return {
id: 'agent-v2',
type: 'custom',
position: { x: 400, y: 180 },
data: {
id: 'agent-v2',
type: 'agent',
title: 'Agent',
desc: '',
agent_binding: { binding_type: 'roster_agent', agent_id: agentId },
agent_node_kind: 'dify_agent',
version: '2',
agent_task: 'Decide whether the request is accepted or rejected.',
agent_output_routes: { enabled: true, routes: agentOutputRoutes },
},
}
}
export function createRoutedAgentWorkflowDraft(agentId: string): SyncDraftWorkflowPayload {
const agent = createRoutedAgentNode(agentId)
const destinations = [
...agentOutputRoutes.map((route) => ({ id: route.id, title: route.label })),
{ id: 'fail-branch', title: 'Failure' },
]
return {
graph: {
nodes: [
{
id: 'start',
type: 'custom',
position: { x: 50, y: 180 },
data: { id: 'start', type: 'start', title: 'Start', variables: [] },
},
{ ...agent, data: { ...agent.data, error_strategy: 'fail-branch' } },
...destinations.map((destination, index) => ({
id: `${destination.id}-end`,
type: 'custom',
position: { x: 800, y: 50 + index * 190 },
data: {
id: `${destination.id}-end`,
type: 'end',
title: `${destination.title} result`,
outputs: [],
},
})),
],
edges: [
{
id: 'start-agent',
type: 'custom',
source: 'start',
target: agent.id,
sourceHandle: 'source',
targetHandle: 'target',
data: { sourceType: 'start', targetType: 'agent' },
},
...destinations.map((destination) => ({
id: `agent-${destination.id}`,
type: 'custom',
source: agent.id,
target: `${destination.id}-end`,
sourceHandle: destination.id,
targetHandle: 'target',
data: { sourceType: 'agent', targetType: 'end' },
})),
],
viewport: { x: 40, y: 40, zoom: 0.7 },
},
features: {},
conversation_variables: [],
}
}
@@ -0,0 +1,7 @@
@workflow-preview @agent-v2 @authenticated @cloud-catalog-runtime
Feature: Workflow template orchestration preview
Scenario: A routed Agent displays its successful and failure connections in the template preview
Given I am signed in as the default E2E admin
And the Cloud catalog contains a published workflow with Agent output routes and a failure branch
When I open the routed workflow template orchestration details
Then the template preview should show each Agent output route and its connection alongside the failure branch
@@ -0,0 +1,214 @@
import type { DifyWorld } from '../../support/world.ts'
import { Given, Then, When } from '@cucumber/cucumber'
import { zWorkflowOutputRoutes } from '@dify/contracts/api/console/apps/zod.gen'
import { expect } from '@playwright/test'
import * as z from 'zod'
import { createTestApp } from '../../../support/api/apps.ts'
import { createE2EResourceName } from '../../../support/naming.ts'
import {
createConfiguredTestAgent,
publishAgentWithPublishableDraft,
} from '../../agent-v2/support/agent.ts'
import {
createRoutedAgentNode,
createRoutedAgentWorkflowDraft,
} from '../../agent-v2/support/output-routes.ts'
import { getAgentV2WorkflowNodeData } from '../../agent-v2/support/workflow.ts'
const zSnippetInsertionGraph = z.object({
nodes: z.array(z.object({ id: z.string(), data: z.object({ title: z.string() }) })),
edges: z.array(z.object({ source: z.string(), target: z.string() })),
})
Given(
'a workflow with two Agent v2 output routes and a failure branch has been created via API',
async function (this: DifyWorld) {
const client = this.getConsoleClient()
const agent = await createConfiguredTestAgent(client)
this.createdAgentIds.push(agent.id)
await publishAgentWithPublishableDraft(client, agent.id)
const app = await createTestApp(
client,
createE2EResourceName('App', 'agent-output-routes'),
'workflow',
)
this.createdAppIds.push(app.id)
this.lastCreatedAppName = app.name
await client.apps.byAppId.workflows.draft.post({
body: createRoutedAgentWorkflowDraft(agent.id),
params: { app_id: app.id },
})
},
)
Given(
'a published snippet ending in an Agent v2 with output routes has been created via API',
async function (this: DifyWorld) {
const client = this.getConsoleClient()
const agent = await createConfiguredTestAgent(client)
this.createdAgentIds.push(agent.id)
await publishAgentWithPublishableDraft(client, agent.id)
const name = createE2EResourceName('Snippet', 'agent-output-routes')
const imported = await client.workspaces.current.customizedSnippets.imports.post({
body: {
mode: 'yaml-content',
yaml_content: JSON.stringify({
version: '0.2.0',
kind: 'snippet',
snippet: { name, description: '', type: 'node' },
workflow: {
graph: {
nodes: [createRoutedAgentNode(agent.id)],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
},
}),
},
})
if (!imported.snippet_id) throw new Error(`Snippet fixture import failed: ${imported.error}`)
this.createdSnippetIds.push(imported.snippet_id)
expect(imported.status).toBe('completed')
this.agentBuilder.workflow.outputRouteSnippet = { id: imported.snippet_id, name }
await client.snippets.bySnippetId.workflows.publish.post({
body: {},
params: { snippet_id: imported.snippet_id },
})
},
)
When('I drag the Rejected Agent output route above Accepted', async function (this: DifyWorld) {
const page = this.getPage()
const rejected = page.getByRole('button', { name: 'Rejected', exact: true })
const accepted = page.getByRole('button', { name: 'Accepted', exact: true })
await rejected.dragTo(accepted)
await expect(page.getByRole('button', { name: /^(Rejected|Accepted)$/ })).toHaveText([
'Rejected',
'Accepted',
])
})
Then(
'the Agent output route order should be saved as Rejected then Accepted',
async function (this: DifyWorld) {
const appId = this.createdAppIds.at(-1)
if (!appId) throw new Error('Create an Agent output routes workflow first.')
await expect
.poll(
async () => {
const data = await getAgentV2WorkflowNodeData(this.getConsoleClient(), appId)
return zWorkflowOutputRoutes
.parse(data.agent_output_routes)
.routes?.map((route) => route.id)
},
{ timeout: 30_000 },
)
.toEqual(['rejected', 'accepted'])
},
)
When('I insert the routed Agent snippet between Start and End', async function (this: DifyWorld) {
const page = this.getPage()
const snippet = this.agentBuilder.workflow.outputRouteSnippet
if (!snippet) throw new Error('Create an Agent output routes snippet first.')
const connection = page.getByRole('button', { name: 'Connection from Start to End', exact: true })
await expect(connection).toBeVisible({ timeout: 30_000 })
const bounds = await connection.boundingBox()
if (!bounds) throw new Error('The Start to End connection has no visible bounds.')
// The Add Node control overlays the middle of the connection when it is hovered.
await connection.hover({ position: { x: bounds.width / 4, y: bounds.height / 2 } })
await page
.locator('.react-flow__edgelabel-renderer')
.getByRole('button', { name: 'Add Node', exact: true })
.click()
await page.getByRole('tab', { name: 'Snippets', exact: true }).click()
await page.getByRole('searchbox', { name: 'Search snippets...' }).fill(snippet.name)
await page.getByRole('button', { name: snippet.name, exact: true }).click()
await expect(page.getByTitle('Agent', { exact: true })).toBeVisible()
})
Then(
'the inserted Agent should receive the Start connection and leave its output routes unconnected',
async function (this: DifyWorld) {
const appId = this.createdAppIds.at(-1)
if (!appId) throw new Error('Create the destination workflow first.')
await expect(
this.getPage().getByLabel('Connection from Start to Agent', { exact: true }),
).toBeVisible()
await expect
.poll(
async () => {
const draft = await this.getConsoleClient().apps.byAppId.workflows.draft.get({
params: { app_id: appId },
})
const graph = zSnippetInsertionGraph.parse(draft.graph)
const agent = graph.nodes.find((node) => node.data.title === 'Agent')
return {
inserted: !!agent,
incoming: graph.edges.filter(
(edge) => edge.source === 'start' && edge.target === agent?.id,
).length,
outgoing: graph.edges.filter((edge) => edge.source === agent?.id).length,
}
},
{ timeout: 30_000 },
)
.toEqual({ inserted: true, incoming: 1, outgoing: 0 })
},
)
Then(
'the Agent output route editor should show Rejected before Accepted',
async function (this: DifyWorld) {
await expect(this.getPage().getByRole('button', { name: /^(Rejected|Accepted)$/ })).toHaveText([
'Rejected',
'Accepted',
])
},
)
When(
'I disable the Agent output routes and undo the saved change once',
async function (this: DifyWorld) {
const page = this.getPage()
const appId = this.createdAppIds.at(-1)
if (!appId) throw new Error('Create an Agent output routes workflow first.')
const outputRoutes = page.getByRole('switch', { name: 'Output Routes', exact: true })
await expect(outputRoutes).toBeChecked()
await outputRoutes.click()
await expect(outputRoutes).not.toBeChecked()
await expect
.poll(
async () => {
const data = await getAgentV2WorkflowNodeData(this.getConsoleClient(), appId)
return zWorkflowOutputRoutes.parse(data.agent_output_routes).enabled
},
{ timeout: 30_000 },
)
.toBe(false)
await page.getByRole('button', { name: 'Undo', exact: true }).click()
},
)
Then(
'the Agent output routes and both successful connections should be restored',
async function (this: DifyWorld) {
const page = this.getPage()
await expect(page.getByRole('switch', { name: 'Output Routes', exact: true })).toBeChecked()
for (const route of ['Accepted', 'Rejected']) {
await expect(
page.getByRole('button', {
name: `Connection from Agent (${route}) to ${route} result`,
exact: true,
}),
).toBeVisible()
}
},
)
@@ -0,0 +1,122 @@
import type { DifyWorld } from '../../support/world.ts'
import { Given, Then, When } from '@cucumber/cucumber'
import { zWorkflowOutputRoutes } from '@dify/contracts/api/console/apps/zod.gen'
import { expect } from '@playwright/test'
import * as z from 'zod'
import { defaultLocale } from '../../../test-env.ts'
const fixtureAppIdEnv = 'E2E_AGENT_OUTPUT_ROUTES_PREVIEW_APP_ID'
const zPreviewGraph = z.object({
nodes: z.array(
z.object({
id: z.string(),
data: z.object({
type: z.string(),
agent_node_kind: z.string().optional(),
version: z.string().optional(),
agent_output_routes: zWorkflowOutputRoutes.optional(),
error_strategy: z.string().optional(),
}),
}),
),
edges: z.array(
z.object({
id: z.string(),
source: z.string(),
sourceHandle: z.string().nullish(),
target: z.string(),
}),
),
})
Given(
'the Cloud catalog contains a published workflow with Agent output routes and a failure branch',
async function (this: DifyWorld) {
const appId = process.env[fixtureAppIdEnv]?.trim()
if (!appId)
throw new Error(`Set ${fixtureAppIdEnv} to the preseeded E2E workflow template app ID.`)
const client = this.getConsoleClient()
const systemFeatures = await client.systemFeatures.get()
expect(systemFeatures.deployment_edition, 'Template preview requires a Cloud runtime.').toBe(
'CLOUD',
)
const [catalog, app, workflow] = await Promise.all([
client.explore.apps.get({ query: { language: defaultLocale } }),
client.trialApps.byAppId.get({ params: { app_id: appId } }),
client.trialApps.byAppId.workflows.get({ params: { app_id: appId } }),
])
expect(app.name, 'The template fixture must be an E2E-owned resource.').toMatch(/^E2E /)
expect(app.mode).toBe('workflow')
expect(
catalog.recommended_apps
.filter((item) => item.app?.name === app.name)
.map((item) => item.app_id),
).toEqual([appId])
expect(workflow.version).toBeTruthy()
expect(workflow.version).not.toBe('draft')
const graph = zPreviewGraph.parse(workflow.graph)
const agents = graph.nodes.filter(
(node) =>
node.data.agent_node_kind === 'dify_agent' &&
node.data.version === '2' &&
node.data.agent_output_routes?.enabled,
)
expect(agents, 'The template must contain exactly one routed Agent.').toHaveLength(1)
const agent = agents[0]!
expect(agent.data.error_strategy).toBe('fail-branch')
const routes = agent.data.agent_output_routes?.routes ?? []
expect(routes.length).toBeGreaterThanOrEqual(2)
const edgeForHandle = (handle: string) => {
const edge = graph.edges.find(
(item) => item.source === agent.id && item.sourceHandle === handle,
)
if (!edge || !graph.nodes.some((node) => node.id === edge.target))
throw new Error(`Template fixture is missing a valid Agent connection for ${handle}.`)
return edge.id
}
this.workflowPreview = {
appName: app.name,
agentNodeId: agent.id,
failureEdgeId: edgeForHandle('fail-branch'),
routes: routes.map((route) => {
if (!route.label?.trim()) throw new Error(`Template route ${route.id} must have a label.`)
return { edgeId: edgeForHandle(route.id), label: route.label }
}),
}
},
)
When('I open the routed workflow template orchestration details', async function (this: DifyWorld) {
const fixture = this.workflowPreview
if (!fixture) throw new Error('Resolve the Cloud workflow template fixture first.')
const page = this.getPage()
await page.goto('/')
await page.getByRole('button', { name: fixture.appName, exact: true }).click()
await page
.getByRole('dialog')
.getByRole('tab', { name: 'Orchestration Details', exact: true })
.click()
})
Then(
'the template preview should show each Agent output route and its connection alongside the failure branch',
async function (this: DifyWorld) {
const fixture = this.workflowPreview
if (!fixture) throw new Error('Resolve the Cloud workflow template fixture first.')
const preview = this.getPage().getByRole('dialog')
await expect(preview.getByTestId(`rf__edge-${fixture.failureEdgeId}`)).toBeVisible({
timeout: 30_000,
})
const agent = preview.getByTestId(`rf__node-${fixture.agentNodeId}`)
for (const route of fixture.routes) {
await expect(preview.getByTestId(`rf__edge-${route.edgeId}`)).toBeVisible()
await expect(agent.getByText(route.label, { exact: true })).toBeVisible()
}
},
)
+8
View File
@@ -176,6 +176,14 @@ After(
await this.getConsoleClient().apps.byAppId.delete({ params: { app_id: id } })
},
})),
...this.createdSnippetIds.toReversed().map((id) => ({
label: `Delete snippet ${id}`,
run: async () => {
await this.getConsoleClient().workspaces.current.customizedSnippets.bySnippetId.delete({
params: { snippet_id: id },
})
},
})),
...this.createdAgentIds.toReversed().map((id) => ({
label: `Delete Agent ${id}`,
run: async () => {
+11
View File
@@ -41,6 +41,12 @@ export type AgentBuilderSpeechToTextRequest = {
path: string
status: number
}
export type WorkflowPreviewFixture = {
appName: string
agentNodeId: string
failureEdgeId: string
routes: { edgeId: string; label: string }[]
}
export const createAgentBuilderWorldState = () => ({
fixtures: {
@@ -67,6 +73,7 @@ export const createAgentBuilderWorldState = () => ({
},
workflow: {
agentConsolePage: undefined as Page | undefined,
outputRouteSnippet: undefined as { id: string; name: string } | undefined,
outputVariables: [] as AgentV2WorkflowOutputVariable[],
},
})
@@ -87,6 +94,7 @@ export class DifyWorld extends World {
lastCreatedAgentName: string | undefined
lastCreatedAgentRole: string | undefined
createdAppIds: string[] = []
createdSnippetIds: string[] = []
createdAgentIds: string[] = []
createdDatasetIds: string[] = []
createdAgentConfigFiles: CreatedAgentConfigFile[] = []
@@ -97,6 +105,7 @@ export class DifyWorld extends World {
capturedDownloads: Download[] = []
shareURL: string | undefined
sharedAppPage: Page | undefined
workflowPreview: WorkflowPreviewFixture | undefined
constructor(options: IWorldOptions) {
super(options)
@@ -104,6 +113,7 @@ export class DifyWorld extends World {
}
resetScenarioState() {
this.workflowPreview = undefined
this.consoleErrors = []
this.pageErrors = []
this.lastCreatedAppName = undefined
@@ -111,6 +121,7 @@ export class DifyWorld extends World {
this.lastCreatedAgentName = undefined
this.lastCreatedAgentRole = undefined
this.createdAppIds = []
this.createdSnippetIds = []
this.createdAgentIds = []
this.createdDatasetIds = []
this.createdAgentConfigFiles = []
+2
View File
@@ -79,6 +79,7 @@ export const validateE2eEnv = () =>
E2E_AGENT_DECISION_MODEL_NAME: process.env.E2E_AGENT_DECISION_MODEL_NAME,
E2E_AGENT_DECISION_MODEL_PROVIDER: process.env.E2E_AGENT_DECISION_MODEL_PROVIDER,
E2E_AGENT_DECISION_MODEL_TYPE: process.env.E2E_AGENT_DECISION_MODEL_TYPE,
E2E_AGENT_OUTPUT_ROUTES_PREVIEW_APP_ID: process.env.E2E_AGENT_OUTPUT_ROUTES_PREVIEW_APP_ID,
E2E_API_URL: process.env.E2E_API_URL,
E2E_BASE_URL: process.env.E2E_BASE_URL,
E2E_BROWSER: process.env.E2E_BROWSER,
@@ -112,6 +113,7 @@ export const validateE2eEnv = () =>
E2E_AGENT_DECISION_MODEL_NAME: z.string().min(1).optional(),
E2E_AGENT_DECISION_MODEL_PROVIDER: z.string().min(1).optional(),
E2E_AGENT_DECISION_MODEL_TYPE: z.string().min(1).optional(),
E2E_AGENT_OUTPUT_ROUTES_PREVIEW_APP_ID: z.uuid().optional(),
E2E_API_URL: z.url().optional(),
E2E_BASE_URL: z.url().optional(),
E2E_BROWSER: z.enum(['chromium', 'webkit']).optional(),
+1 -1
View File
@@ -16,7 +16,7 @@ const hasCustomTags = (forwardArgs: string[]) =>
forwardArgs.some((arg) => arg === '--tags' || arg.startsWith('--tags='))
const fullNonExternalTags =
'not @axe and not @prepared and not @external-model and not @external-tool'
'not @axe and not @prepared and not @external-model and not @external-tool and not @cloud-catalog-runtime'
const seedCeleryQueues = 'dataset,priority_dataset,workflow_based_app_execution'
const readLogTail = async (logFilePath: string) => {
+1 -6
View File
@@ -3585,12 +3585,7 @@
},
"web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx": {
"typescript/no-explicit-any": {
"count": 15
}
},
"web/app/components/workflow/nodes/question-classifier/components/class-item.tsx": {
"jsx-a11y/no-autofocus": {
"count": 1
"count": 14
}
},
"web/app/components/workflow/nodes/question-classifier/use-single-run-form-params.ts": {
@@ -639,6 +639,7 @@ export type WorkflowNodeJobConfig = {
human_contacts?: Array<AgentHumanContactConfig>
metadata?: WorkflowNodeJobMetadata
mode?: WorkflowNodeJobMode
output_routes?: WorkflowOutputRoutes
previous_node_output_refs?: Array<WorkflowPreviousNodeOutputRef>
schema_version?: number
workflow_prompt?: string
@@ -1161,6 +1162,11 @@ export type WorkflowNodeJobMetadata = {
export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do'
export type WorkflowOutputRoutes = {
enabled?: boolean
routes?: Array<WorkflowOutputRoute>
}
export type WorkflowPreviousNodeOutputRef = {
key?: string | null
name?: string | null
@@ -1524,6 +1530,12 @@ export type AgentFileRefConfig = {
[key: string]: unknown
}
export type WorkflowOutputRoute = {
id: string
label?: string | null
name?: string
}
export type AgentCliToolAuthorizationStatus =
| 'allowed'
| 'authorized'
@@ -1580,6 +1580,27 @@ export const zWorkflowNodeJobMetadata = z.object({
file_refs: z.array(zAgentFileRefConfig).nullish(),
})
/**
* WorkflowOutputRoute
*
* Stable workflow exit identity and its model-visible selection condition.
*/
export const zWorkflowOutputRoute = z.object({
id: z.string().min(1),
label: z.string().nullish(),
name: z.string().optional().default(''),
})
/**
* WorkflowOutputRoutes
*
* Enabled routes require at least two exits; drafts may omit conditions.
*/
export const zWorkflowOutputRoutes = z.object({
enabled: z.boolean().optional().default(false),
routes: z.array(zWorkflowOutputRoute).optional(),
})
/**
* AgentCliToolAuthorizationStatus
*
@@ -2059,6 +2080,7 @@ export const zWorkflowNodeJobConfig = z.object({
human_contacts: z.array(zAgentHumanContactConfig).optional(),
metadata: zWorkflowNodeJobMetadata.optional(),
mode: zWorkflowNodeJobMode.optional().default('tell_agent_what_to_do'),
output_routes: zWorkflowOutputRoutes.optional(),
previous_node_output_refs: z.array(zWorkflowPreviousNodeOutputRef).optional(),
schema_version: z.int().optional().default(1),
workflow_prompt: z.string().optional().default(''),
@@ -1959,6 +1959,7 @@ export type WorkflowNodeJobConfig = {
human_contacts?: Array<AgentHumanContactConfig>
metadata?: WorkflowNodeJobMetadata
mode?: WorkflowNodeJobMode
output_routes?: WorkflowOutputRoutes
previous_node_output_refs?: Array<WorkflowPreviousNodeOutputRef>
schema_version?: number
workflow_prompt?: string
@@ -2411,6 +2412,11 @@ export type WorkflowNodeJobMetadata = {
export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do'
export type WorkflowOutputRoutes = {
enabled?: boolean
routes?: Array<WorkflowOutputRoute>
}
export type WorkflowPreviousNodeOutputRef = {
key?: string | null
name?: string | null
@@ -2701,6 +2707,12 @@ export type DeclaredOutputRetryConfig = {
retry_interval_ms?: number
}
export type WorkflowOutputRoute = {
id: string
label?: string | null
name?: string
}
export type AgentCliToolAuthorizationStatus =
| 'allowed'
| 'authorized'
@@ -3146,6 +3146,27 @@ export const zDeclaredOutputFailureStrategy = z.object({
retry: zDeclaredOutputRetryConfig.optional(),
})
/**
* WorkflowOutputRoute
*
* Stable workflow exit identity and its model-visible selection condition.
*/
export const zWorkflowOutputRoute = z.object({
id: z.string().min(1),
label: z.string().nullish(),
name: z.string().optional().default(''),
})
/**
* WorkflowOutputRoutes
*
* Enabled routes require at least two exits; drafts may omit conditions.
*/
export const zWorkflowOutputRoutes = z.object({
enabled: z.boolean().optional().default(false),
routes: z.array(zWorkflowOutputRoute).optional(),
})
/**
* AgentCliToolAuthorizationStatus
*
@@ -3500,6 +3521,7 @@ export const zWorkflowNodeJobConfig = z.object({
human_contacts: z.array(zAgentHumanContactConfig).optional(),
metadata: zWorkflowNodeJobMetadata.optional(),
mode: zWorkflowNodeJobMode.optional().default('tell_agent_what_to_do'),
output_routes: zWorkflowOutputRoutes.optional(),
previous_node_output_refs: z.array(zWorkflowPreviousNodeOutputRef).optional(),
schema_version: z.int().optional().default(1),
workflow_prompt: z.string().optional().default(''),
@@ -451,6 +451,7 @@ export type WorkflowNodeJobConfig = {
human_contacts?: Array<AgentHumanContactConfig>
metadata?: WorkflowNodeJobMetadata
mode?: WorkflowNodeJobMode
output_routes?: WorkflowOutputRoutes
previous_node_output_refs?: Array<WorkflowPreviousNodeOutputRef>
schema_version?: number
workflow_prompt?: string
@@ -701,6 +702,11 @@ export type WorkflowNodeJobMetadata = {
export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do'
export type WorkflowOutputRoutes = {
enabled?: boolean
routes?: Array<WorkflowOutputRoute>
}
export type WorkflowPreviousNodeOutputRef = {
key?: string | null
name?: string | null
@@ -944,6 +950,12 @@ export type DeclaredOutputRetryConfig = {
retry_interval_ms?: number
}
export type WorkflowOutputRoute = {
id: string
label?: string | null
name?: string
}
export type AgentCliToolAuthorizationStatus =
| 'allowed'
| 'authorized'
@@ -927,6 +927,27 @@ export const zDeclaredOutputFailureStrategy = z.object({
retry: zDeclaredOutputRetryConfig.optional(),
})
/**
* WorkflowOutputRoute
*
* Stable workflow exit identity and its model-visible selection condition.
*/
export const zWorkflowOutputRoute = z.object({
id: z.string().min(1),
label: z.string().nullish(),
name: z.string().optional().default(''),
})
/**
* WorkflowOutputRoutes
*
* Enabled routes require at least two exits; drafts may omit conditions.
*/
export const zWorkflowOutputRoutes = z.object({
enabled: z.boolean().optional().default(false),
routes: z.array(zWorkflowOutputRoute).optional(),
})
/**
* AgentCliToolAuthorizationStatus
*
@@ -1237,6 +1258,7 @@ export const zWorkflowNodeJobConfig = z.object({
human_contacts: z.array(zAgentHumanContactConfig).optional(),
metadata: zWorkflowNodeJobMetadata.optional(),
mode: zWorkflowNodeJobMode.optional().default('tell_agent_what_to_do'),
output_routes: zWorkflowOutputRoutes.optional(),
previous_node_output_refs: z.array(zWorkflowPreviousNodeOutputRef).optional(),
schema_version: z.int().optional().default(1),
workflow_prompt: z.string().optional().default(''),
@@ -560,6 +560,82 @@ describe('useInsertSnippet', () => {
expect(insertedLoopStartNode.data.loop_id).toBe(insertedLoopNode.id)
})
it.each([
{ enabled: true, description: 'leaves routed outputs for the user to connect' },
{ enabled: false, description: 'automatically connects the ordinary Agent output' },
])('$description when inserting an Agent snippet into an edge', async ({ enabled }) => {
mockGetNodes.mockReturnValue([
{ id: 'start', position: { x: 0, y: 0 }, data: { type: 'start' } },
{ id: 'end', position: { x: 600, y: 0 }, data: { type: 'end' } },
])
mockEdges = [
{
id: 'start-source-end-target',
source: 'start',
sourceHandle: 'source',
target: 'end',
targetHandle: 'target',
},
]
seedPublishedWorkflow(queryClient, {
graph: {
nodes: [
{
id: 'snippet-agent',
position: { x: 0, y: 0 },
data: {
type: 'agent',
agent_node_kind: 'dify_agent',
version: '2',
agent_output_routes: {
enabled,
routes: [
{ id: 'accepted', name: 'Accepted', label: 'The request is accepted.' },
{ id: 'rejected', name: 'Rejected', label: 'The request is rejected.' },
],
},
},
},
],
edges: [],
},
})
const { result } = renderUseInsertSnippet()
await act(async () => {
await result.current.handleInsertSnippet('snippet-1', {
prevNodeId: 'start',
prevNodeSourceHandle: 'source',
nextNodeId: 'end',
nextNodeTargetHandle: 'target',
})
})
const nextNodes = mockSetNodes.mock.calls[0]![0] as TestNode[]
const insertedAgent = nextNodes.find((node) => node.data.type === 'agent')!
const nextEdges = mockSetEdges.mock.calls[0]![0] as TestEdge[]
const expectedEdges = [
expect.objectContaining({
source: 'start',
sourceHandle: 'source',
target: insertedAgent.id,
targetHandle: 'target',
}),
]
if (!enabled) {
expectedEdges.push(
expect.objectContaining({
source: insertedAgent.id,
sourceHandle: 'source',
target: 'end',
targetHandle: 'target',
}),
)
}
expect(nextEdges).toEqual(expectedEdges)
})
it.each(['iteration', 'loop'] as const)(
'should connect inserted snippet nodes inside the %s container',
async (containerType) => {
@@ -13,6 +13,7 @@ import { CUSTOM_EDGE, NESTED_ELEMENT_Z_INDEX, NODE_WIDTH_X_OFFSET, X_OFFSET } fr
import { useNodesSyncDraft } from '../../hooks/use-nodes-sync-draft'
import { useWorkflowHistory, WorkflowHistoryEvent } from '../../hooks/use-workflow-history'
import { getNodeUsedVars, updateNodeVars } from '../../nodes/_base/components/variable/utils'
import { hasAgentV2OutputRoutes } from '../../nodes/agent-v2/types'
import { AssignerNodeInputType } from '../../nodes/assigner/types'
import { BlockEnum } from '../../types'
import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../../utils'
@@ -65,6 +66,7 @@ const canConnectFromSource = (node: Node) => {
return (
node.data.type !== BlockEnum.IfElse &&
node.data.type !== BlockEnum.QuestionClassifier &&
!hasAgentV2OutputRoutes(node.data) &&
node.data.type !== BlockEnum.HumanInput &&
node.data.type !== BlockEnum.LoopEnd
)
@@ -410,7 +410,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
isReadyForCheckValid,
}
} else {
usedVars = getNodeUsedVars(node!).filter((v) => v.length > 0)
usedVars = getNodeUsedVars(node!, { forExecution: true }).filter((v) => v.length > 0)
}
if (node!.data.type === BlockEnum.LLM) {
@@ -802,7 +802,7 @@ export const useChecklistBeforePublish = () => {
isReadyForCheckValid,
}
} else {
usedVars = getNodeUsedVars(node!).filter((v) => v.length > 0)
usedVars = getNodeUsedVars(node!, { forExecution: true }).filter((v) => v.length > 0)
}
if (node!.data.type === BlockEnum.LLM) {
@@ -3,6 +3,7 @@ import type { CommonNodeType } from '../types'
import { useLayoutEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore } from 'reactflow'
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
import { ErrorHandleTypeEnum } from '../nodes/_base/components/error-handle/types'
import { BlockEnum } from '../types'
@@ -30,6 +31,14 @@ export const useEdgeAccessibleLabel = (
branchName = branchName ? `${classLabel}: ${branchName}` : classLabel
}
}
if (hasAgentV2OutputRoutes(sourceData)) {
const routes = sourceData.agent_output_routes?.routes ?? []
const index = routes.findIndex((route) => route.id === sourceHandleId)
if (index >= 0)
branchName =
routes[index]?.label ||
t(($) => $['nodes.agent.outputRoutes.route'], { ns: 'workflow', index: index + 1 })
}
if (sourceData.type === BlockEnum.HumanInput) {
branchName =
sourceHandleId === '__timeout'
@@ -70,16 +70,20 @@ export const useEdgesInteractions = () => {
[getNodesReadOnly, store],
)
const handleEdgeDeleteByDeleteBranch = useCallback(
(nodeId: string, branchId: string) => {
if (getNodesReadOnly()) return
// The caller owns draft synchronization and history for the complete user action.
const removeBranchEdges = useCallback(
(nodeId: string, branchIds: string[]) => {
if (getNodesReadOnly()) return false
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
const edgeWillBeDeleted = edges.filter(
(edge) => edge.source === nodeId && edge.sourceHandle === branchId,
(edge) =>
edge.source === nodeId &&
edge.sourceHandle != null &&
branchIds.includes(edge.sourceHandle),
)
if (!edgeWillBeDeleted.length) return
if (!edgeWillBeDeleted.length) return false
const newNodes = applyConnectedHandleNodeData(
nodes,
@@ -98,16 +102,19 @@ export const useEdgesInteractions = () => {
) {
workflowStore.setState({ contextMenuTarget: undefined })
}
return true
},
[getNodesReadOnly, collaborativeWorkflow, workflowStore],
)
const handleEdgeDeleteByDeleteBranch = useCallback(
(nodeId: string, branchId: string) => {
if (!removeBranchEdges(nodeId, [branchId])) return
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.EdgeDeleteByDeleteBranch)
},
[
getNodesReadOnly,
collaborativeWorkflow,
workflowStore,
handleSyncWorkflowDraft,
saveStateToHistory,
],
[removeBranchEdges, handleSyncWorkflowDraft, saveStateToHistory],
)
const handleEdgeDelete = useCallback(() => {
@@ -220,6 +227,7 @@ export const useEdgesInteractions = () => {
return {
handleEdgeEnter,
handleEdgeLeave,
removeBranchEdges,
handleEdgeDeleteByDeleteBranch,
handleEdgeDelete,
handleEdgeDeleteById,
@@ -32,7 +32,11 @@ import {
} from '../constants'
import { getNodeUsedVars } from '../nodes/_base/components/variable/utils'
import { useCreateInlineAgentBinding } from '../nodes/agent-v2/hooks'
import { isAgentV2NodeData, needsInlineAgentBindingCreation } from '../nodes/agent-v2/types'
import {
hasAgentV2OutputRoutes,
isAgentV2NodeData,
needsInlineAgentBindingCreation,
} from '../nodes/agent-v2/types'
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions'
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
@@ -387,6 +391,7 @@ export const useNodesInteractions = () => {
connectingNode.data.type === BlockEnum.VariableAggregator) &&
node.data.type !== BlockEnum.IfElse &&
node.data.type !== BlockEnum.QuestionClassifier &&
!hasAgentV2OutputRoutes(node.data) &&
node.data.type !== BlockEnum.HumanInput
) {
n.data._isEntering = true
@@ -1017,6 +1022,7 @@ export const useNodesInteractions = () => {
if (
nodeType !== BlockEnum.IfElse &&
nodeType !== BlockEnum.QuestionClassifier &&
!hasAgentV2OutputRoutes(newNode.data) &&
nodeType !== BlockEnum.HumanInput
) {
newNode.data._connectedSourceHandleIds = [sourceHandle]
@@ -1051,6 +1057,7 @@ export const useNodesInteractions = () => {
if (
nodeType !== BlockEnum.IfElse &&
nodeType !== BlockEnum.QuestionClassifier &&
!hasAgentV2OutputRoutes(newNode.data) &&
nodeType !== BlockEnum.HumanInput &&
nodeType !== BlockEnum.LoopEnd
) {
@@ -1212,6 +1219,7 @@ export const useNodesInteractions = () => {
if (
nodeType !== BlockEnum.IfElse &&
nodeType !== BlockEnum.QuestionClassifier &&
!hasAgentV2OutputRoutes(newNode.data) &&
nodeType !== BlockEnum.HumanInput &&
nodeType !== BlockEnum.LoopEnd
) {
@@ -3,6 +3,7 @@ import { produce } from 'immer'
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
@@ -40,6 +41,11 @@ export const useWorkflowNodeFinished = () => {
if (data.node_type === BlockEnum.QuestionClassifier)
currentNode.data._runningBranchId = data?.outputs?.class_id
if (
hasAgentV2OutputRoutes(currentNode.data) &&
data.status === NodeRunningStatus.Succeeded
)
currentNode.data._runningBranchId = data.outputs?.switch
if (data.node_type === BlockEnum.HumanInput)
currentNode.data._runningBranchId = data?.outputs?.__action_id
}
@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import type { Topic } from '../types'
import type { Topic } from './types'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
@@ -19,9 +19,12 @@ type Props = Readonly<{
payload: Topic
onChange: (payload: Topic) => void
onRemove: () => void
showRemove?: boolean
index: number
readonly?: boolean
filterVar: (payload: Var, valueSelector: ValueSelector) => boolean
placeholder?: string
defaultLabel?: string
onLabelEditStart?: () => void
}>
@@ -32,21 +35,28 @@ const ClassItem: FC<Props> = ({
payload,
onChange,
onRemove,
showRemove = true,
index,
readonly,
filterVar,
onLabelEditStart,
placeholder,
defaultLabel,
}) => {
const { t } = useTranslation()
const reactId = useId()
const [isEditingLabel, setIsEditingLabel] = useState(false)
const [draftLabel, setDraftLabel] = useState('')
const labelInputRef = useRef<HTMLInputElement>(null)
const displayLabel = getDisplayClassLabel(payload.label, index, t)
const displayLabel =
payload.label?.trim() || defaultLabel || getDisplayClassLabel(payload.label, index, t)
const instanceId = `${nodeId}-${reactId}`
useEffect(() => {
if (isEditingLabel) labelInputRef.current?.select()
if (isEditingLabel) {
labelInputRef.current?.focus()
labelInputRef.current?.select()
}
}, [isEditingLabel])
const handleNameChange = useCallback(
@@ -58,7 +68,9 @@ const ClassItem: FC<Props> = ({
const handleLabelSave = useCallback(
(nextValue: string) => {
const normalizedLabel = getCanonicalClassLabel(nextValue, index, t)
const normalizedLabel = defaultLabel
? nextValue.trim() || defaultLabel
: getCanonicalClassLabel(nextValue, index, t)
setIsEditingLabel(false)
setDraftLabel(normalizedLabel)
const shouldPersistLabel =
@@ -66,7 +78,7 @@ const ClassItem: FC<Props> = ({
(payload.label !== undefined && payload.label !== normalizedLabel)
if (shouldPersistLabel) onChange({ ...payload, label: normalizedLabel })
},
[displayLabel, index, onChange, payload, t],
[displayLabel, index, onChange, payload, t, defaultLabel],
)
const handleLabelCancel = useCallback(() => {
@@ -93,7 +105,9 @@ const ClassItem: FC<Props> = ({
<input
ref={labelInputRef}
value={draftLabel}
aria-label={t(($) => $[`${i18nPrefix}.labelEditorAriaLabel`], { ns: 'workflow' })}
aria-label={
defaultLabel ?? t(($) => $[`${i18nPrefix}.labelEditorAriaLabel`], { ns: 'workflow' })
}
className={cn(
'h-6 w-full rounded-md border border-divider-regular bg-components-input-bg-normal px-2 text-xs font-semibold text-text-secondary ring-0 outline-none',
'focus:border-components-input-border-active',
@@ -113,7 +127,6 @@ const ClassItem: FC<Props> = ({
handleLabelCancel()
}
}}
autoFocus
/>
) : readonly ? (
<div className="-ml-1 px-1 py-0.5 text-left text-xs/4 font-semibold text-text-secondary">
@@ -143,10 +156,12 @@ const ClassItem: FC<Props> = ({
className={className}
headerClassName={headerClassName}
title={title}
placeholder={t(($) => $[`${i18nPrefix}.topicPlaceholder`], { ns: 'workflow' })!}
placeholder={
placeholder ?? t(($) => $[`${i18nPrefix}.topicPlaceholder`], { ns: 'workflow' })!
}
value={payload.name}
onChange={handleNameChange}
showRemove
showRemove={showRemove}
onRemove={onRemove}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
@@ -31,12 +31,20 @@ const normalizeClassLabel = (label?: string | null) => label?.trim() ?? ''
export const getDefaultClassLabel = (_t: TFunction, index: number) =>
getCanonicalDefaultClassLabel(index)
export const getDisplayClassLabel = (label: string | undefined, index: number, t: TFunction) =>
export const getDisplayClassLabel = (
label: string | null | undefined,
index: number,
t: TFunction,
) =>
normalizeClassLabel(label) ||
getTranslatedDefaultClassLabel(t, index) ||
getCanonicalDefaultClassLabel(index)
export const isDefaultClassLabel = (label: string | undefined, index: number, t: TFunction) => {
export const isDefaultClassLabel = (
label: string | null | undefined,
index: number,
t: TFunction,
) => {
const normalizedLabel = normalizeClassLabel(label)
if (!normalizedLabel) return true
@@ -46,7 +54,11 @@ export const isDefaultClassLabel = (label: string | undefined, index: number, t:
)
}
export const getCanonicalClassLabel = (label: string | undefined, index: number, t: TFunction) => {
export const getCanonicalClassLabel = (
label: string | null | undefined,
index: number,
t: TFunction,
) => {
const normalizedLabel = normalizeClassLabel(label)
if (!normalizedLabel) return getCanonicalDefaultClassLabel(index)
@@ -0,0 +1,237 @@
'use client'
import type { FC, ReactNode } from 'react'
import type { Topic } from './types'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ReactSortable } from 'react-sortablejs'
import { useKeyboardSortable } from '@/app/components/base/keyboard-sortable/use-keyboard-sortable'
import { useEdgesInteractions } from '../../../../hooks/use-edges-interactions'
import AddButton from '../add-button'
import {
Collapse,
CollapseActions,
CollapseContent,
CollapseHeader,
CollapseIndicator,
CollapseTitle,
CollapseTrigger,
} from '../collapse'
import Item from './class-item'
import { getDefaultClassLabel, isDefaultClassLabel } from './class-label-utils'
import { useInlineLabelHintDismissed } from './storage'
const i18nPrefix = 'nodes.questionClassifiers'
type Props = Readonly<{
nodeId: string
list: Topic[]
minItems?: number
enabled?: boolean
actions?: ReactNode
onChange: (list: Topic[]) => void
/** Owns both the list update and edge removal instead of the default removal action. */
onRemove?: (id: string) => void
readonly?: boolean
filterVar: (payload: Var, valueSelector: ValueSelector) => boolean
labels?: {
title: string
add: string
placeholder: string
renameHint: string
defaultLabel: (index: number) => string
}
handleSortTopic?: (newTopics: (Topic & { id: string })[]) => void
}>
const ClassList: FC<Props> = ({
nodeId,
list,
minItems = 0,
enabled = true,
actions,
onChange,
onRemove,
readonly,
filterVar,
labels,
handleSortTopic = noop,
}) => {
const { t } = useTranslation()
const { handleEdgeDeleteByDeleteBranch } = useEdgesInteractions()
const [collapsed, setCollapsed] = useState(false)
const [storedRenameHintDismissed, setIsRenameHintDismissed] = useInlineLabelHintDismissed()
const isRenameHintDismissed = storedRenameHintDismissed ?? false
const handleClassChange = useCallback(
(index: number) => {
return (value: Topic) => {
const newList = produce(list, (draft) => {
draft[index] = value
})
onChange(newList)
}
},
[list, onChange],
)
const handleAddClass = useCallback(() => {
const newList = produce(list, (draft) => {
draft.push({
id: crypto.randomUUID(),
name: '',
label: labels?.defaultLabel(draft.length + 1) ?? getDefaultClassLabel(t, draft.length + 1),
})
})
onChange(newList)
}, [list, onChange, t, labels])
const handleRemoveClass = useCallback(
(index: number) => {
return () => {
if (list.length <= minItems) return
if (onRemove) {
onRemove(list[index]!.id)
return
}
const newList = produce(list, (draft) => {
draft.splice(index, 1)
})
onChange(newList)
handleEdgeDeleteByDeleteBranch(nodeId, list[index]!.id)
}
},
[list, minItems, onChange, onRemove, handleEdgeDeleteByDeleteBranch, nodeId],
)
const keyboardSort = useKeyboardSortable({
items: list,
onChange: handleSortTopic,
disabled: readonly,
getItemLabel: (item) => item.label || item.name,
})
const sortableTopics = useMemo(
() => keyboardSort.items.map((item) => ({ ...item })),
[keyboardSort.items],
)
const topicCount = list.length
const dismissRenameHint = useCallback(() => {
if (isRenameHintDismissed) return
setIsRenameHintDismissed(true)
}, [isRenameHintDismissed, setIsRenameHintDismissed])
const shouldShowRenameHint =
!readonly &&
!isRenameHintDismissed &&
list.some((item, index) => {
return labels
? item.label === labels.defaultLabel(index + 1)
: isDefaultClassLabel(item.label, index + 1, t)
})
return (
<Collapse collapsed={!enabled || collapsed} onCollapse={setCollapsed} disabled={!enabled}>
{keyboardSort.announcement}
<CollapseHeader>
<CollapseTrigger className="ml-0">
<CollapseTitle>
{labels?.title ?? t(($) => $[`${i18nPrefix}.class`], { ns: 'workflow' })}{' '}
{enabled && <span className="text-text-destructive">*</span>}
</CollapseTitle>
{enabled && list.length > 0 && <CollapseIndicator />}
</CollapseTrigger>
{actions != null && <CollapseActions>{actions}</CollapseActions>}
</CollapseHeader>
{enabled && (
<CollapseContent>
<div className="pt-2">
{shouldShowRenameHint && (
<div className="mb-2 rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-xs text-text-tertiary">
{labels?.renameHint ?? t(($) => $[`${i18nPrefix}.renameHint`], { ns: 'workflow' })}
</div>
)}
<div className="overflow-y-visible pl-3">
<ReactSortable
list={sortableTopics}
setList={(items) => {
if (
!keyboardSort.isSorting &&
items.some((item, index) => item.id !== sortableTopics[index]?.id)
)
handleSortTopic(items)
}}
handle=".handle"
ghostClass="bg-components-panel-bg"
animation={150}
disabled={readonly || keyboardSort.isSorting}
className="space-y-2"
>
{keyboardSort.items.map((item, index) => {
const canDrag = !readonly && topicCount >= 2
return (
<div
key={item.id}
className={cn(
'group relative -ml-3 min-h-10 rounded-[10px] bg-components-panel-bg px-0 py-0',
)}
style={{
// Performance hint for browser
contain: 'layout style paint',
}}
>
<div>
{canDrag && (
<IconButton
{...keyboardSort.getHandleProps(index)}
className="handle pointer-events-none absolute top-1.5 left-0.5 z-10 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus:pointer-events-auto focus:opacity-100 aria-pressed:pointer-events-auto aria-pressed:opacity-100"
>
<span aria-hidden="true" className="i-ri-draggable size-3" />
</IconButton>
)}
<Item
className={cn(canDrag && 'handle')}
headerClassName={cn(
canDrag && 'cursor-grab group-focus-within:pl-5 group-hover:pl-5',
)}
nodeId={nodeId}
key={item.id}
payload={item}
onChange={handleClassChange(keyboardSort.getItemKey(index))}
onRemove={handleRemoveClass(keyboardSort.getItemKey(index))}
showRemove={list.length > minItems}
index={index + 1}
readonly={readonly}
filterVar={filterVar}
onLabelEditStart={dismissRenameHint}
placeholder={labels?.placeholder}
defaultLabel={labels?.defaultLabel(index + 1)}
/>
</div>
</div>
)
})}
</ReactSortable>
</div>
{!readonly && (
<div className="mt-2">
<AddButton
onClick={handleAddClass}
text={labels?.add ?? t(($) => $[`${i18nPrefix}.addClass`], { ns: 'workflow' })}
/>
</div>
)}
</div>
</CollapseContent>
)}
</Collapse>
)
}
export default React.memo(ClassList)
@@ -0,0 +1,96 @@
import type { TFunction } from 'i18next'
import type { FC } from 'react'
import type { Topic } from './types'
import type { NodeProps } from '@/app/components/workflow/types'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useTranslation } from 'react-i18next'
import { NodeSourceHandle } from '../node-handle'
import ReadonlyInputWithSelectVar from '../readonly-input-with-select-var'
import { getDisplayClassLabel } from './class-label-utils'
const MAX_CLASS_TEXT_LENGTH = 50
type TruncatedClassItemProps = {
topic: { id: string; name: string; label?: string | null }
index: number
nodeId: string
t: TFunction
defaultLabel?: string
}
const TruncatedClassItem: FC<TruncatedClassItemProps> = ({
topic,
index,
nodeId,
t,
defaultLabel,
}) => {
const displayLabel =
topic.label?.trim() || defaultLabel || getDisplayClassLabel(topic.label, index + 1, t)
const truncatedText =
topic.name.length > MAX_CLASS_TEXT_LENGTH
? `${topic.name.slice(0, MAX_CLASS_TEXT_LENGTH)}...`
: topic.name
const shouldShowTooltip = topic.name.length > MAX_CLASS_TEXT_LENGTH
const content = (
<div className="truncate system-xs-regular text-text-tertiary">
<ReadonlyInputWithSelectVar value={truncatedText} nodeId={nodeId} className="truncate" />
</div>
)
return (
<div className="flex flex-col gap-y-0.5 rounded-md bg-workflow-block-parma-bg px-1.25 py-0.75">
<div className="text-xs/4 font-semibold text-text-secondary">{displayLabel}</div>
{shouldShowTooltip ? (
<Popover>
<PopoverTrigger
openOnHover
aria-label={topic.name}
className="w-full border-0 bg-transparent p-0 text-left"
>
{content}
</PopoverTrigger>
<PopoverContent className="max-w-75 px-3 py-2 system-xs-regular wrap-break-word text-text-tertiary">
<ReadonlyInputWithSelectVar value={topic.name} nodeId={nodeId} />
</PopoverContent>
</Popover>
) : (
content
)}
</div>
)
}
export function NodeBranches({
node,
branches,
defaultLabel,
}: {
node: NodeProps
branches: Topic[]
defaultLabel?: (index: number) => string
}) {
const { t } = useTranslation()
return (
<div className="mt-2 space-y-0.5">
{branches.map((topic, index) => (
<div key={topic.id} className="relative">
<TruncatedClassItem
topic={topic}
index={index}
nodeId={node.id}
t={t}
defaultLabel={defaultLabel?.(index + 1)}
/>
<NodeSourceHandle
{...node}
handleId={topic.id}
handleClassName="top-1/2! -translate-y-1/2! -right-[21px]!"
/>
</div>
))}
</div>
)
}
@@ -0,0 +1,5 @@
export type Topic = {
id: string
name: string
label?: string | null
}
@@ -179,6 +179,22 @@ describe('error-handle path', () => {
expect(onSelected.mock.calls[0]?.[0]).toBe(ErrorHandleTypeEnum.defaultValue)
})
it('offers only stop and failure-branch strategies when default values are unsupported', async () => {
const user = userEvent.setup()
render(
<ErrorHandleTypeSelector
value={ErrorHandleTypeEnum.none}
onSelected={vi.fn()}
allowDefaultValue={false}
/>,
)
await user.click(screen.getByRole('button'))
expect(screen.getAllByRole('menuitemradio').map((item) => item.textContent)).toEqual([
expect.stringContaining('workflow.nodes.common.errorHandle.none.title'),
expect.stringContaining('workflow.nodes.common.errorHandle.failBranch.title'),
])
})
it('should render the error tip only when a strategy exists', () => {
const { rerender, container } = render(<ErrorHandleTip />)
@@ -2,6 +2,7 @@ import type { DefaultValueForm } from './types'
import type { CommonNodeType, Node } from '@/app/components/workflow/types'
import { Infotip, InfotipContent, InfotipTrigger } from '@langgenius/dify-ui/infotip'
import { useTranslation } from 'react-i18next'
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
import {
Collapse,
CollapseActions,
@@ -58,6 +59,7 @@ const ErrorHandle = ({ id, data }: ErrorHandleProps) => {
<ErrorHandleTypeSelector
value={error_strategy || ErrorHandleTypeEnum.none}
onSelected={handleTypeChange}
allowDefaultValue={!hasAgentV2OutputRoutes(data)}
/>
</div>
</CollapseActions>
@@ -12,9 +12,14 @@ import { ErrorHandleTypeEnum } from './types'
type ErrorHandleTypeSelectorProps = {
value: ErrorHandleTypeEnum
allowDefaultValue?: boolean
onSelected: (value: ErrorHandleTypeEnum) => void
}
const ErrorHandleTypeSelector = ({ value, onSelected }: ErrorHandleTypeSelectorProps) => {
const ErrorHandleTypeSelector = ({
value,
onSelected,
allowDefaultValue = true,
}: ErrorHandleTypeSelectorProps) => {
const { t } = useTranslation()
const options = [
{
@@ -33,6 +38,9 @@ const ErrorHandleTypeSelector = ({ value, onSelected }: ErrorHandleTypeSelectorP
description: t(($) => $['nodes.common.errorHandle.failBranch.desc'], { ns: 'workflow' }),
},
]
const availableOptions = options.filter(
(option) => allowDefaultValue || option.value !== ErrorHandleTypeEnum.defaultValue,
)
const selectedOption = options.find((option) => option.value === value)
return (
@@ -56,7 +64,7 @@ const ErrorHandleTypeSelector = ({ value, onSelected }: ErrorHandleTypeSelectorP
className="w-70 rounded-xl border-[0.5px] bg-components-panel-bg-blur p-1"
>
<DropdownMenuRadioGroup value={value} onValueChange={onSelected}>
{options.map((option) => (
{availableOptions.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
@@ -4,6 +4,7 @@ import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { getConnectedEdges, getOutgoers, useStore } from 'reactflow'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
import { hasErrorHandleNode } from '@/app/components/workflow/utils'
import BlockIcon from '../../../../block-icon'
import { useToolIcon } from '../../../../hooks/use-tool-icon'
@@ -65,7 +66,13 @@ const NextStep = ({ selectedNode }: NextStepProps) => {
name:
data.type === BlockEnum.QuestionClassifier
? `${t(($) => $['nodes.questionClassifiers.class'], { ns: 'workflow' })} ${index + 1}`
: branch.name,
: hasAgentV2OutputRoutes(data)
? data.agent_output_routes?.routes?.[index]?.label ||
t(($) => $['nodes.agent.outputRoutes.route'], {
ns: 'workflow',
index: index + 1,
})
: branch.name,
},
nextNodes,
}
@@ -83,25 +90,25 @@ const NextStep = ({ selectedNode }: NextStepProps) => {
nextNodes,
},
]
}
if (data.error_strategy === ErrorHandleTypeEnum.failBranch && hasErrorHandleNode(data.type)) {
const connected = connectedEdges.filter(
(edge) => edge.sourceHandle === ErrorHandleTypeEnum.failBranch,
)
const nextNodes = resolveNextNodes(connected)
if (data.error_strategy === ErrorHandleTypeEnum.failBranch && hasErrorHandleNode(data.type)) {
const connected = connectedEdges.filter(
(edge) => edge.sourceHandle === ErrorHandleTypeEnum.failBranch,
)
const nextNodes = resolveNextNodes(connected)
items.push({
branch: {
id: ErrorHandleTypeEnum.failBranch,
name: t(($) => $['common.onFailure'], { ns: 'workflow' }),
},
nextNodes,
})
}
items.push({
branch: {
id: ErrorHandleTypeEnum.failBranch,
name: t(($) => $['common.onFailure'], { ns: 'workflow' }),
},
nextNodes,
})
}
return items
}, [branches, connectedEdges, data.error_strategy, data.type, outgoers, t])
}, [branches, connectedEdges, data, outgoers, t])
return (
<div className="flex py-1">
@@ -6,6 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Handle, Position } from 'reactflow'
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
import BlockSelector from '../../../block-selector'
import { useAvailableBlocks } from '../../../hooks/use-available-blocks'
import { useNodesInteractions } from '../../../hooks/use-nodes-interactions'
@@ -177,6 +178,7 @@ export const NodeSourceHandle = memo(
const hasMultipleSourceHandles =
data.type === BlockEnum.IfElse ||
data.type === BlockEnum.QuestionClassifier ||
hasAgentV2OutputRoutes(data) ||
data.type === BlockEnum.HumanInput ||
data.error_strategy === ErrorHandleTypeEnum.failBranch
const handleOpenChange = useCallback<NonNullable<BlockSelectorProps['onOpenChange']>>(
@@ -16,7 +16,7 @@ import {
VarType,
} from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
import { getNodeUsedVars, toNodeAvailableVars, updateNodeVars } from '../utils'
import { findUsedVarNodes, getNodeUsedVars, toNodeAvailableVars, updateNodeVars } from '../utils'
const createNode = <T>(data: Node<T>['data']): Node<T> => ({
id: 'node-1',
@@ -438,6 +438,51 @@ describe('variable utils', () => {
})
})
it.each([
{ enabled: false, count: 2 },
{ enabled: true, count: 2 },
])(
'maintains saved route references separately from execution inputs: %j',
({ enabled, count }) => {
const node = createNode<AgentV2NodeType>({
type: BlockEnum.AgentV2,
title: 'Agent',
desc: '',
version: '2',
agent_node_kind: 'dify_agent',
agent_output_routes: {
enabled,
routes: Array.from({ length: count }, (_, index) => ({
id: `route-${index}`,
name: '{{#upstream.foo#}}',
})),
},
})
const users = findUsedVarNodes(['upstream', 'foo'], [node])
expect(users).toEqual([node])
expect(getNodeUsedVars(node, { forExecution: true })).toEqual(
enabled ? [['upstream', 'foo']] : [],
)
const renamed = updateNodeVars(
users[0]!,
['upstream', 'foo'],
['upstream', 'bar'],
) as Node<AgentV2NodeType>
expect(renamed.data.agent_output_routes?.routes?.map((route) => route.name)).toEqual(
Array.from({ length: count }, () => '{{#upstream.bar#}}'),
)
const copied = updateNodeVars(
renamed,
['upstream', 'bar'],
['copy', 'bar'],
) as Node<AgentV2NodeType>
expect(getNodeUsedVars(copied)).toContainEqual(['copy', 'bar'])
// Text references remain visible after deletion, as in Question Classifier conditions.
const deleted = updateNodeVars(copied, ['copy', 'bar'], [])
expect(findUsedVarNodes(['copy', 'bar'], [deleted])).toEqual([deleted])
},
)
it('keeps datasource output variables typed through the public picker projection', () => {
const provider = createDatasourceProvider()
provider.declaration.datasources![0]!.output_schema = {
@@ -1228,7 +1228,8 @@ const replaceOldVarInPromptItem = (
: {}),
})
export const getNodeUsedVars = (node: Node): ValueSelector[] => {
/** Include saved references for rename/delete/copy; execution excludes inactive route conditions. */
export const getNodeUsedVars = (node: Node, { forExecution = false } = {}): ValueSelector[] => {
const { data } = node
const { type } = data
let res: ValueSelector[] = []
@@ -1341,7 +1342,12 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
case BlockEnum.Agent: {
if (isAgentV2NodeData(data)) {
const payload = data as AgentV2NodeType
res = matchNotSystemVars([payload.agent_task || ''])
res = matchNotSystemVars([
payload.agent_task || '',
...(!forExecution || payload.agent_output_routes?.enabled
? (payload.agent_output_routes?.routes?.map((route) => route.name ?? '') ?? [])
: []),
])
break
}
@@ -1358,7 +1364,12 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
}
case BlockEnum.AgentV2: {
const payload = data as AgentV2NodeType
res = matchNotSystemVars([payload.agent_task || ''])
res = matchNotSystemVars([
payload.agent_task || '',
...(!forExecution || payload.agent_output_routes?.enabled
? (payload.agent_output_routes?.routes?.map((route) => route.name ?? '') ?? [])
: []),
])
break
}
case BlockEnum.DataSource: {
@@ -1734,6 +1745,9 @@ export const updateNodeVars = (
oldVarSelector,
newVarSelector,
)
payload.agent_output_routes?.routes?.forEach((route) => {
route.name = replaceOldVarInText(route.name ?? '', oldVarSelector, newVarSelector)
})
break
}
@@ -1779,6 +1793,9 @@ export const updateNodeVars = (
oldVarSelector,
newVarSelector,
)
payload.agent_output_routes?.routes?.forEach((route) => {
route.name = replaceOldVarInText(route.name ?? '', oldVarSelector, newVarSelector)
})
break
}
case BlockEnum.DataSource: {
@@ -10,6 +10,7 @@ import BlockIcon from '@/app/components/workflow/block-icon'
import { ToolType } from '@/app/components/workflow/block-selector/types'
import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
import { useNodeIterationInteractions } from '@/app/components/workflow/nodes/iteration/use-interactions'
import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/use-interactions'
import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id'
@@ -226,6 +227,7 @@ const BaseNode: FC<BaseNodeProps> = ({ id, data, children }) => {
{data.type !== BlockEnum.StartPlaceholder &&
data.type !== BlockEnum.IfElse &&
data.type !== BlockEnum.QuestionClassifier &&
!hasAgentV2OutputRoutes(data) &&
data.type !== BlockEnum.HumanInput &&
!data._isCandidate && (
<NodeSourceHandle
@@ -112,6 +112,26 @@ describe('agent/default', () => {
})
})
it.each([
{ enabled: false, conditions: [''], valid: true },
{ enabled: true, conditions: [], valid: false },
{ enabled: true, conditions: ['Accept'], valid: false },
{ enabled: true, conditions: ['Accept', ' '], valid: false },
{ enabled: true, conditions: ['Accept', 'Reject'], valid: true },
])('requires at least two complete routes when enabled: %j', ({ enabled, conditions, valid }) => {
const result = nodeDefault.checkValid(
createPayload({
agent_output_routes: {
enabled,
routes: conditions.map((name, index) => ({ id: `route-${index}`, name })),
},
}),
t,
)
expect(result.isValid).toBe(valid)
})
it('links directly to the New Agent section of the shared agent node document', () => {
expect(nodeDefault.metaData.helpLinkUri).toBe('agent#new-agent')
})
@@ -1,7 +1,10 @@
import type { ReactNode } from 'react'
import type { AgentV2NodeType } from '../types'
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { renderWorkflowFlowComponent as render } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum } from '@/app/components/workflow/types'
import { ErrorHandleTypeEnum } from '../../_base/components/error-handle/types'
import BaseNode from '../../_base/node'
import { AgentV2Node } from '../node'
const { mockUseAgentRosterDetail, mockUseWorkflowInlineAgentDetail } = vi.hoisted(() => ({
@@ -28,12 +31,29 @@ vi.mock('../../_base/components/setting-item', () => ({
),
}))
vi.mock('../hooks', () => ({
vi.mock('../hooks', async (importOriginal) => ({
...(await importOriginal<typeof import('../hooks')>()),
useAgentRosterDetail: (agentId?: string) => mockUseAgentRosterDetail(agentId),
useWorkflowInlineAgentDetail: (nodeId?: string, agentId?: string | null) =>
mockUseWorkflowInlineAgentDetail(nodeId, agentId),
useCreateInlineAgentBinding: () => ({ createInlineAgentBinding: vi.fn() }),
}))
vi.mock('../../../hooks/use-tool-icon', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../hooks/use-tool-icon')>()),
useToolIcon: () => undefined,
}))
vi.mock('@/app/components/workflow/collaboration/hooks/use-collaboration', () => ({
useCollaboration: () => ({ nodePanelPresence: {} }),
}))
// BaseNode reads plugin permissions even when rendering an Agent node.
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => ({ workspacePermissionKeys: [] }))
})
const createData = (overrides: Partial<AgentV2NodeType> = {}): AgentV2NodeType => ({
title: 'Agent',
desc: '',
@@ -171,4 +191,37 @@ describe('agent/node', () => {
'workflow.errorMsg.fieldRequired',
)
})
it('switches the composed card between route handles and source while preserving the failure exit', () => {
const data = createData({
error_strategy: ErrorHandleTypeEnum.failBranch,
agent_output_routes: {
enabled: true,
routes: [
{ id: 'accepted', name: 'Accepted' },
{ id: 'rejected', name: 'Rejected' },
],
},
})
const card = (data: AgentV2NodeType) => (
<BaseNode id="agent-node" data={data}>
<AgentV2Node id="agent-node" data={data} />
</BaseNode>
)
const { container, rerender } = render(card(data))
// ReactFlow consumes these IDs to connect and restore workflow edges.
const sourceHandles = () =>
Array.from(container.querySelectorAll('.react-flow__handle.source'), (handle) =>
handle.getAttribute('data-handleid'),
).sort()
expect(sourceHandles()).toEqual(['accepted', 'fail-branch', 'rejected'])
rerender(
card({
...data,
agent_output_routes: { ...data.agent_output_routes!, enabled: false },
}),
)
expect(sourceHandles()).toEqual(['fail-branch', 'source'])
})
})
@@ -8,6 +8,8 @@ import { BlockEnum } from '@/app/components/workflow/types'
import { FlowType } from '@/types/common'
import { AgentV2Panel } from '../panel'
vi.mock('../components/agent-output-routes', () => ({ AgentOutputRoutes: () => null }))
const {
mockEditorFocus,
mockEditorUpdate,
@@ -0,0 +1,152 @@
import type { ReactNode } from 'react'
import type { AgentV2NodeType } from '../../types'
import type { Edge, Node } from '@/app/components/workflow/types'
import { act, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEdges, useNodes } from 'reactflow'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { useNodesInteractions } from '@/app/components/workflow/hooks/use-nodes-interactions'
import { BlockEnum } from '@/app/components/workflow/types'
import { AgentOutputRoutes } from '../agent-output-routes'
vi.mock('../../../_base/hooks/use-available-var-list', () => ({
default: () => ({ availableVars: [], availableNodesWithParent: [] }),
}))
vi.mock('../../hooks', async (importOriginal) => ({
...(await importOriginal<typeof import('../../hooks')>()),
useCreateInlineAgentBinding: () => ({ createInlineAgentBinding: vi.fn() }),
}))
// Route history depends on the list mutation, not the rich text editor's token handling.
vi.mock('../../../_base/components/prompt/editor', () => ({
default: ({
title,
value,
onRemove,
showRemove,
}: {
title: ReactNode
value: string
onRemove: () => void
showRemove?: boolean
}) => (
<div>
{title}
<textarea aria-label="Condition" value={value} readOnly />
{showRemove && (
<button type="button" onClick={onRemove} aria-label={`Remove ${value}`}>
Remove route
</button>
)}
</div>
),
}))
function HistoryEditor() {
const nodes = useNodes<AgentV2NodeType>()
const edges = useEdges()
const { handleHistoryBack } = useNodesInteractions()
const agent = nodes.find((node) => node.id === 'agent')
if (!agent) return null
return (
<>
<AgentOutputRoutes id={agent.id} data={agent.data} />
<button type="button" onClick={handleHistoryBack}>
Undo
</button>
<output aria-label="Connections">{edges.map((edge) => edge.id).join(',')}</output>
</>
)
}
function renderHistoryEditor({ connected = false }: { connected?: boolean } = {}) {
const routes = [
{ id: 'accepted', name: 'Accept', label: 'Accepted' },
{ id: 'rejected', name: 'Reject', label: 'Rejected' },
...(connected ? [{ id: 'deferred', name: 'Defer', label: 'Deferred' }] : []),
]
const agent: Node<AgentV2NodeType> = {
id: 'agent',
type: 'default',
position: { x: 0, y: 0 },
data: {
type: BlockEnum.AgentV2,
title: 'Agent',
desc: '',
version: '2',
agent_node_kind: 'dify_agent',
agent_output_routes: { enabled: true, routes },
_targetBranches: routes,
_connectedSourceHandleIds: connected ? ['accepted'] : [],
},
}
const nodes: Node[] = [agent]
const edges: Edge[] = []
if (connected) {
nodes.push({
id: 'end',
type: 'default',
position: { x: 300, y: 0 },
data: {
type: BlockEnum.End,
title: 'End',
desc: '',
_connectedTargetHandleIds: ['target'],
},
})
edges.push({
id: 'accepted-result',
source: 'agent',
sourceHandle: 'accepted',
target: 'end',
targetHandle: 'target',
data: { sourceType: BlockEnum.AgentV2, targetType: BlockEnum.End },
})
}
return renderWorkflowFlowComponent(<HistoryEditor />, {
nodes,
edges,
historyStore: { nodes, edges },
hooksStoreProps: { doSyncWorkflowDraft: vi.fn() },
})
}
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
})
it('restores enabled routes with one undo when disabling them removes no edges', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderHistoryEditor()
const toggle = screen.getByRole('switch', { name: 'workflow.nodes.agent.outputRoutes.title' })
await user.click(toggle)
expect(toggle).not.toBeChecked()
await act(() => vi.advanceTimersByTimeAsync(500))
await user.click(screen.getByRole('button', { name: 'Undo' }))
expect(toggle).toBeChecked()
expect(screen.getAllByRole('textbox', { name: 'Condition' })).toHaveLength(2)
})
it('restores a deleted route and its connection with one undo', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderHistoryEditor({ connected: true })
await user.click(screen.getByRole('button', { name: 'Remove Accept' }))
expect(screen.queryByRole('button', { name: 'Accepted' })).not.toBeInTheDocument()
expect(screen.getByLabelText('Connections')).toBeEmptyDOMElement()
await act(() => vi.advanceTimersByTimeAsync(500))
await user.click(screen.getByRole('button', { name: 'Undo' }))
expect(screen.getByRole('button', { name: 'Accepted' })).toBeVisible()
expect(screen.getAllByRole('textbox', { name: 'Condition' })).toHaveLength(3)
expect(screen.getByLabelText('Connections')).toHaveTextContent('accepted-result')
})
@@ -0,0 +1,224 @@
import type { ReactNode } from 'react'
import type { AgentV2NodeType } from '../../types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { ErrorHandleTypeEnum } from '../../../_base/components/error-handle/types'
import { getAgentV2DeclaredOutputs } from '../../output-variables'
import { AgentOutputRoutes } from '../agent-output-routes'
const { removeEdges, removeVariable, saveHistory, update } = vi.hoisted(() => ({
removeEdges: vi.fn(),
removeVariable: vi.fn(),
saveHistory: vi.fn(),
update: vi.fn(),
}))
vi.mock('@/app/components/workflow/hooks/use-node-data-update', () => ({
useNodeDataUpdate: () => ({ handleNodeDataUpdate: update }),
}))
vi.mock('@/app/components/workflow/hooks/use-edges-interactions', () => ({
useEdgesInteractions: () => ({ removeBranchEdges: removeEdges }),
}))
vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: vi.fn() }),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false, getNodesReadOnly: () => false }),
useWorkflow: () => ({ removeUsedVarInNodes: removeVariable }),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow-history', () => ({
useWorkflowHistory: () => ({ saveStateToHistory: saveHistory }),
WorkflowHistoryEvent: { NodeChange: 'NodeChange' },
}))
vi.mock('../../../_base/hooks/use-available-var-list', () => ({
default: () => ({ availableVars: [], availableNodesWithParent: [] }),
}))
vi.mock('react-sortablejs', () => ({
ReactSortable: ({ children }: { children: ReactNode }) => <>{children}</>,
}))
// The rich text editor's own tests cover variable tokens; this boundary exercises
// the route editor's persisted condition, label, and list mutations.
vi.mock('../../../_base/components/prompt/editor', () => ({
default: ({
title,
value,
onChange,
onRemove,
showRemove,
}: {
title: ReactNode
value: string
onChange: (value: string) => void
onRemove: () => void
showRemove?: boolean
}) => (
<div>
{title}
<textarea
aria-label="Condition"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
{showRemove && (
<button type="button" onClick={onRemove}>
Remove route
</button>
)}
</div>
),
}))
function Harness({
initial,
errorStrategy,
}: {
initial?: AgentV2NodeType['agent_output_routes']
errorStrategy?: ErrorHandleTypeEnum
}) {
const [data, setData] = useState<AgentV2NodeType>({
type: BlockEnum.AgentV2,
title: 'Agent',
desc: '',
version: '2',
agent_node_kind: 'dify_agent',
agent_output_routes: initial,
error_strategy: errorStrategy,
default_value:
errorStrategy === ErrorHandleTypeEnum.defaultValue
? [{ key: 'text', type: VarType.string, value: 'fallback' }]
: undefined,
})
update.mockImplementation(({ data: changed }: { data: Partial<AgentV2NodeType> }) =>
setData((data) => ({ ...data, ...changed })),
)
return (
<>
<AgentOutputRoutes id="agent" data={data} />
<output aria-label="Available outputs">
{getAgentV2DeclaredOutputs(data)
.map((output) => output.name)
.join(',')}
</output>
</>
)
}
beforeEach(() => {
vi.clearAllMocks()
})
it('enables two routes, preserves edited conditions through toggles, and changes success handles', async () => {
const user = userEvent.setup()
render(<Harness initial={{ enabled: false, routes: [] }} />)
const toggle = screen.getByRole('switch', { name: 'workflow.nodes.agent.outputRoutes.title' })
await user.click(toggle)
expect(screen.getAllByRole('textbox', { name: 'Condition' })).toHaveLength(2)
expect(screen.getByLabelText('Available outputs')).toHaveTextContent('text,switch')
expect(removeEdges).toHaveBeenCalledWith('agent', ['source'])
await user.type(screen.getAllByRole('textbox', { name: 'Condition' })[0]!, 'Request approved')
const saved = update.mock.lastCall![0].data.agent_output_routes
await user.click(toggle)
expect(removeEdges).toHaveBeenCalledWith(
'agent',
saved.routes.map((route: { id: string }) => route.id),
)
expect(removeVariable).toHaveBeenCalledWith(['agent', 'switch'])
expect(screen.getByLabelText('Available outputs').textContent).toBe('text')
await user.click(toggle)
expect(screen.getAllByRole('textbox', { name: 'Condition' })[0]).toHaveValue('Request approved')
expect(update.mock.lastCall![0].data.agent_output_routes.routes).toEqual(saved.routes)
expect(saveHistory).toHaveBeenCalledWith('NodeChange')
})
it('removes a route by stable ID and keeps at least two routes with switch available', async () => {
const user = userEvent.setup()
render(
<Harness
initial={{
enabled: true,
routes: [
{ id: 'accepted', name: 'Accept' },
{ id: 'rejected', name: 'Reject' },
{ id: 'deferred', name: 'Defer' },
],
}}
/>,
)
await user.click(screen.getAllByRole('button', { name: 'Remove route' })[0]!)
expect(removeEdges).toHaveBeenCalledWith('agent', ['accepted'])
expect(removeVariable).not.toHaveBeenCalled()
expect(update.mock.lastCall![0].data._targetBranches).toEqual([
{ id: 'rejected', name: 'Reject' },
{ id: 'deferred', name: 'Defer' },
])
expect(screen.getByLabelText('Available outputs').textContent).toBe('text,switch')
expect(screen.getAllByRole('textbox', { name: 'Condition' })).toHaveLength(2)
expect(screen.queryByRole('button', { name: 'Remove route' })).not.toBeInTheDocument()
})
it('collapses routes independently of their enabled state and keeps one title', async () => {
const user = userEvent.setup()
render(
<Harness
initial={{
enabled: true,
routes: [
{ id: 'accepted', name: 'Accept' },
{ id: 'rejected', name: 'Reject' },
],
}}
/>,
)
const title = 'workflow.nodes.agent.outputRoutes.title'
const disclosure = screen.getByRole('button', { name: new RegExp(title) })
const toggle = screen.getByRole('switch', { name: title })
expect(screen.getAllByText(title, { exact: true })).toHaveLength(1)
expect(disclosure).toHaveAttribute('aria-expanded', 'true')
await user.click(disclosure)
expect(disclosure).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('textbox', { name: 'Condition' })).not.toBeInTheDocument()
expect(toggle).toBeChecked()
expect(update).not.toHaveBeenCalled()
expect(removeEdges).not.toHaveBeenCalled()
await user.click(disclosure)
expect(screen.getAllByRole('textbox', { name: 'Condition' })[0]).toHaveValue('Accept')
await user.click(toggle)
expect(disclosure).toHaveAttribute('aria-disabled', 'true')
await user.click(disclosure)
expect(screen.queryByRole('textbox', { name: 'Condition' })).not.toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.agent.outputRoutes.add')).not.toBeInTheDocument()
await user.click(toggle)
expect(disclosure).not.toHaveAttribute('aria-disabled', 'true')
expect(disclosure).toHaveAttribute('aria-expanded', 'true')
expect(screen.getAllByRole('textbox', { name: 'Condition' })).toHaveLength(2)
})
it('keeps a saved disabled route and adds only the missing route when enabling', async () => {
const user = userEvent.setup()
const saved = { id: 'accepted', name: 'Accept', label: 'Accepted' }
render(<Harness initial={{ enabled: false, routes: [saved] }} />)
await user.click(screen.getByRole('switch', { name: 'workflow.nodes.agent.outputRoutes.title' }))
const routes = update.mock.lastCall![0].data.agent_output_routes.routes
expect(routes).toHaveLength(2)
expect(routes[0]).toEqual(saved)
expect(routes[1]).toMatchObject({ id: expect.any(String), name: '' })
expect(routes[1].id).not.toBe(saved.id)
expect(screen.getAllByRole('textbox', { name: 'Condition' })[0]).toHaveValue('Accept')
expect(screen.queryByRole('button', { name: 'Remove route' })).not.toBeInTheDocument()
})
it('clears node-level default values when enabling routes', async () => {
const user = userEvent.setup()
render(<Harness errorStrategy={ErrorHandleTypeEnum.defaultValue} />)
await user.click(screen.getByRole('switch', { name: 'workflow.nodes.agent.outputRoutes.title' }))
expect(update.mock.lastCall![0].data).toMatchObject({
agent_output_routes: { enabled: true },
error_strategy: undefined,
default_value: undefined,
})
})
@@ -0,0 +1,106 @@
import type { WorkflowOutputRoutes } from '@dify/contracts/api/console/apps/types.gen'
import type { AgentV2NodeType } from '../types'
import type { NodeProps, Var } from '@/app/components/workflow/types'
import { Switch } from '@langgenius/dify-ui/switch'
import { useTranslation } from 'react-i18next'
import { useEdgesInteractions } from '@/app/components/workflow/hooks/use-edges-interactions'
import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft'
import { useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks/use-workflow'
import {
useWorkflowHistory,
WorkflowHistoryEvent,
} from '@/app/components/workflow/hooks/use-workflow-history'
import { VarType } from '@/app/components/workflow/types'
import BranchList from '../../_base/components/branch-list/class-list'
import { ErrorHandleTypeEnum } from '../../_base/components/error-handle/types'
const filterVar = (variable: Var) =>
variable.type === VarType.string || variable.type === VarType.number
export function AgentOutputRoutes({ id, data }: NodeProps<AgentV2NodeType>) {
const { t } = useTranslation()
const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly()
const { removeBranchEdges } = useEdgesInteractions()
const { handleNodeDataUpdate } = useNodeDataUpdate()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { saveStateToHistory } = useWorkflowHistory()
const { removeUsedVarInNodes } = useWorkflow()
const routes = data.agent_output_routes
const list = (routes?.routes ?? []).map((route) => ({ ...route, name: route.name ?? '' }))
const defaultLabel = (index: number) =>
t(($) => $['nodes.agent.outputRoutes.route'], { ns: 'workflow', index })
const update = (next: WorkflowOutputRoutes) => {
if (getNodesReadOnly()) return
const nextRoutes = next.routes?.map(({ id, name, label }) => ({ id, name, label }))
const currentHandles = routes?.enabled ? list.map((route) => route.id) : ['source']
const nextHandles = next.enabled ? (nextRoutes ?? []).map((route) => route.id) : ['source']
// Downstream references must be removed before their connecting edges disappear.
if (routes?.enabled && !next.enabled) removeUsedVarInNodes([id, 'switch'])
handleNodeDataUpdate({
id,
data: {
agent_output_routes: { ...next, routes: nextRoutes },
...(next.enabled && data.error_strategy === ErrorHandleTypeEnum.defaultValue
? { error_strategy: undefined, default_value: undefined }
: {}),
_targetBranches: next.enabled
? nextRoutes?.map((route) => ({ id: route.id, name: route.name ?? '' }))
: [],
},
})
removeBranchEdges(
id,
currentHandles.filter((handle) => !nextHandles.includes(handle)),
)
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeChange)
}
const toggle = (enabled: boolean) => {
const nextList = [...list]
if (enabled) {
while (nextList.length < 2)
nextList.push({
id: crypto.randomUUID(),
name: '',
label: defaultLabel(nextList.length + 1),
})
}
update({ enabled, routes: nextList })
}
return (
<div className="border-b border-divider-subtle px-4 py-3">
<BranchList
nodeId={id}
list={list}
minItems={2}
enabled={!!routes?.enabled}
actions={
<Switch
aria-label={t(($) => $['nodes.agent.outputRoutes.title'], { ns: 'workflow' })}
checked={!!routes?.enabled}
disabled={nodesReadOnly}
onCheckedChange={toggle}
/>
}
onChange={(routes) => update({ enabled: true, routes })}
onRemove={(routeId) =>
update({ enabled: true, routes: list.filter((route) => route.id !== routeId) })
}
handleSortTopic={(routes) => update({ enabled: true, routes })}
readonly={nodesReadOnly}
filterVar={filterVar}
labels={{
title: t(($) => $['nodes.agent.outputRoutes.title'], { ns: 'workflow' }),
add: t(($) => $['nodes.agent.outputRoutes.add'], { ns: 'workflow' }),
placeholder: t(($) => $['nodes.agent.outputRoutes.placeholder'], { ns: 'workflow' }),
renameHint: t(($) => $['nodes.agent.outputRoutes.renameHint'], { ns: 'workflow' }),
defaultLabel,
}}
/>
</div>
)
}
@@ -3,7 +3,7 @@ import type {
DeclaredOutputType,
} from '@dify/contracts/api/console/apps/types.gen'
import type { TFunction } from 'i18next'
import { agentV2SystemTextOutput } from '../../output-variables'
import { AGENT_V2_RESERVED_OUTPUT_NAMES } from '../../output-variables'
export type DeclaredOutputChildConfig = NonNullable<DeclaredOutputConfig['children']>[number]
@@ -279,15 +279,13 @@ export function getDefaultValueErrorKey(draft: OutputDraft) {
}
export function isDefaultOutput(output: DeclaredOutputConfig) {
return (
agentV2SystemTextOutput.name === output.name &&
agentV2SystemTextOutput.type === output.type &&
getOutputTypeOptionValue(agentV2SystemTextOutput) === getOutputTypeOptionValue(output)
)
return AGENT_V2_RESERVED_OUTPUT_NAMES.has(output.name)
}
export function getOutputDescription(output: EditableOutputConfig, t: TFunction) {
if (output.name === 'text') return t(($) => $['nodes.agent.outputVars.text'], { ns: 'workflow' })
if (output.name === 'switch')
return t(($) => $['nodes.agent.outputVars.switch'], { ns: 'workflow' })
return output.description || ''
}
@@ -31,6 +31,19 @@ const nodeDefault: NodeDefault<AgentV2NodeType> = {
}
}
const routes = payload.agent_output_routes
if (
routes?.enabled &&
((routes.routes?.length ?? 0) < 2 || routes.routes?.some((route) => !route.name?.trim()))
)
return {
isValid: false,
errorMessage: t(($) => $['errorMsg.fieldRequired'], {
ns: 'workflow',
field: t(($) => $['nodes.agent.outputRoutes.title'], { ns: 'workflow' }),
}),
}
return {
isValid: true,
errorMessage: '',
@@ -1,8 +1,11 @@
import type { NodeProps } from '../../types'
import type { AgentV2NodeType } from './types'
import type { AppIconType } from '@/types/app'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useUpdateNodeInternals } from 'reactflow'
import AppIcon from '@/app/components/base/app-icon'
import { NodeBranches } from '../_base/components/branch-list/node-branches'
import { SettingItem } from '../_base/components/setting-item'
import { useAgentRosterDetail, useWorkflowInlineAgentDetail } from './hooks'
import { hasInlineAgentBinding, hasValidRosterAgentBinding } from './types'
@@ -96,7 +99,12 @@ function AgentNodeModel({
)
}
export function AgentV2Node({ id, data }: NodeProps<AgentV2NodeType>) {
export function AgentV2Node(props: NodeProps<AgentV2NodeType>) {
const { id, data } = props
const updateNodeInternals = useUpdateNodeInternals()
useEffect(() => {
updateNodeInternals(id)
}, [id, data.agent_output_routes, updateNodeInternals])
const { t } = useTranslation()
const hasValidAgent = hasValidRosterAgentBinding(data)
const isInlineAgent = hasInlineAgentBinding(data)
@@ -110,11 +118,27 @@ export function AgentV2Node({ id, data }: NodeProps<AgentV2NodeType>) {
if (isInlineAgent || hasValidAgent)
return (
<AgentNodeModel
data={data}
agent={rosterAgentQuery.data}
isLoading={isInlineAgentDetailLoading}
/>
<>
<AgentNodeModel
data={data}
agent={rosterAgentQuery.data}
isLoading={isInlineAgentDetailLoading}
/>
{data.agent_output_routes?.enabled && (
<div className="px-3 pb-2">
<NodeBranches
node={props}
branches={(data.agent_output_routes.routes ?? []).map((route) => ({
...route,
name: route.name ?? '',
}))}
defaultLabel={(index) =>
t(($) => $['nodes.agent.outputRoutes.route'], { ns: 'workflow', index })
}
/>
</div>
)}
</>
)
return (
@@ -1,4 +1,7 @@
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
import type {
DeclaredOutputConfig,
WorkflowOutputRoutes,
} from '@dify/contracts/api/console/apps/types.gen'
import type { AgentV2NodeType } from './types'
import type { Var } from '@/app/components/workflow/types'
import { VarType } from '@/app/components/workflow/types'
@@ -35,15 +38,29 @@ const arrayItemVarTypes: Record<DeclaredOutputConfig['type'], VarType> = {
}
export function getAgentV2DeclaredOutputs(data: AgentV2NodeType) {
return normalizeAgentV2DeclaredOutputs(data.agent_declared_outputs ?? [])
return normalizeAgentV2DeclaredOutputs(
data.agent_declared_outputs ?? [],
data.agent_output_routes,
)
}
export function getAgentV2CustomDeclaredOutputs(outputs: readonly DeclaredOutputConfig[]) {
return outputs.filter((output) => !AGENT_V2_RESERVED_OUTPUT_NAMES.has(output.name))
}
export function normalizeAgentV2DeclaredOutputs(outputs: readonly DeclaredOutputConfig[]) {
return [agentV2SystemTextOutput, ...getAgentV2CustomDeclaredOutputs(outputs)]
export function normalizeAgentV2DeclaredOutputs(
outputs: readonly DeclaredOutputConfig[],
routes?: WorkflowOutputRoutes,
) {
const systemOutputs: DeclaredOutputConfig[] = [agentV2SystemTextOutput]
if (routes?.enabled)
systemOutputs.push({
name: 'switch',
type: 'string',
required: true,
description: 'Selected output route ID.',
})
return [...systemOutputs, ...getAgentV2CustomDeclaredOutputs(outputs)]
}
function getDeclaredOutputVarType(output: DeclaredOutputConfig) {
@@ -28,6 +28,7 @@ import {
WorkflowInlineAgentConfigureWorkspace,
WorkflowRosterAgentOrchestratePanelContent,
} from './components/agent-orchestrate-panel-content'
import { AgentOutputRoutes } from './components/agent-output-routes'
import { AgentOutputVariables } from './components/agent-output-variables'
import { OutputEditCard } from './components/agent-output-variables/edit-card'
import { createDraft, isDefaultOutput } from './components/agent-output-variables/utils'
@@ -140,11 +141,14 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
const [localDeclaredOutputs, setLocalDeclaredOutputs] = useState<DeclaredOutputConfig[] | null>(
null,
)
const normalizedDeclaredOutputs = useMemo(
() => normalizeAgentV2DeclaredOutputs(inputs.agent_declared_outputs ?? []),
[inputs.agent_declared_outputs],
const declaredOutputs = useMemo(
() =>
normalizeAgentV2DeclaredOutputs(
localDeclaredOutputs ?? inputs.agent_declared_outputs ?? [],
inputs.agent_output_routes,
),
[localDeclaredOutputs, inputs.agent_declared_outputs, inputs.agent_output_routes],
)
const declaredOutputs = localDeclaredOutputs ?? normalizedDeclaredOutputs
const rosterAgentId =
inputs.agent_binding?.binding_type === 'roster_agent'
? inputs.agent_binding.agent_id
@@ -544,7 +548,10 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
setIsOutputVariablesCollapsed(false)
const previousOutputs = getAgentV2DeclaredOutputs(inputsRef.current)
let nextAgentTask = agentTask
let nextOutputs = normalizeAgentV2DeclaredOutputs(outputs)
let nextOutputs = normalizeAgentV2DeclaredOutputs(
outputs,
inputsRef.current.agent_output_routes,
)
if (agentTask !== undefined) {
const nextPromptOutputNames = extractAgentOutputNames(agentTask)
const removedPromptOutputNames = [...promptOutputNamesRef.current].filter(
@@ -727,6 +734,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
onOutputsChange={handleDeclaredOutputsChange}
/>
</div>
<AgentOutputRoutes id={id} data={inputs} />
<div>
<AgentOutputVariables
collapsed={isOutputVariablesCollapsed}
@@ -1,4 +1,7 @@
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
import type {
DeclaredOutputConfig,
WorkflowOutputRoutes,
} from '@dify/contracts/api/console/apps/types.gen'
import type { AgentBinding } from '@/app/components/workflow/block-selector/types'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
@@ -6,6 +9,7 @@ import { BlockEnum } from '@/app/components/workflow/types'
export type AgentV2NodeType = CommonNodeType & {
agent_binding?: AgentBinding
agent_declared_outputs?: DeclaredOutputConfig[]
agent_output_routes?: WorkflowOutputRoutes
agent_node_kind: 'dify_agent'
agent_task?: string
version: '2'
@@ -49,3 +53,7 @@ export function needsInlineAgentBindingCreation(data: AgentV2NodeType) {
export function hasValidAgentBinding(data: AgentV2NodeType) {
return hasValidInlineAgentBinding(data) || hasValidRosterAgentBinding(data)
}
export function hasAgentV2OutputRoutes(data: CommonNodeType): data is AgentV2NodeType {
return isAgentV2NodeData(data) && !!data.agent_output_routes?.enabled
}
@@ -65,7 +65,9 @@ const useSingleRunFormParams = ({
}
> = {}
iterationChildrenNodes.forEach((node) => {
const nodeVars = getNodeUsedVars(node).filter((item) => item && item.length > 0)
const nodeVars = getNodeUsedVars(node, { forExecution: true }).filter(
(item) => item && item.length > 0,
)
nodeVars.forEach((varSelector) => {
if (varSelector[0] === id) {
// skip iteration node itself variable: item, index
@@ -67,7 +67,9 @@ export const buildUsedOutVars = ({
const allVarObject: Record<string, { inSingleRunPassedKey: string }> = {}
loopChildrenNodes.forEach((node) => {
const nodeVars = getNodeUsedVars(node).filter((item) => item && item.length > 0)
const nodeVars = getNodeUsedVars(node, { forExecution: true }).filter(
(item) => item && item.length > 0,
)
nodeVars.forEach((varSelector) => {
if (varSelector[0] === currentNodeId) return
if (isNodeInLoop(varSelector[0]!)) return
@@ -1,4 +1,5 @@
import type { QuestionClassifierNodeType, Topic } from '../types'
import type { QuestionClassifierNodeType } from '../types'
import type { Topic } from '@/app/components/workflow/nodes/_base/components/branch-list/types'
import type { PanelProps } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -6,9 +7,9 @@ import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/comp
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { useEdgesInteractions } from '../../../hooks/use-edges-interactions'
import ClassItem from '../../_base/components/branch-list/class-item'
import ClassList from '../../_base/components/branch-list/class-list'
import AdvancedSetting from '../components/advanced-setting'
import ClassItem from '../components/class-item'
import ClassList from '../components/class-list'
import Node from '../node'
import Panel from '../panel'
import useConfig from '../use-config'
@@ -94,15 +95,6 @@ vi.mock(
}),
)
vi.mock('@/app/components/workflow/nodes/_base/components/collapse', () => ({
FieldCollapse: ({ title, children }: any) => (
<div>
<div>{title}</div>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
default: ({ title, operations, children }: any) => (
<div>
@@ -1,4 +1,5 @@
import type { QuestionClassifierNodeType, Topic } from '../types'
import type { QuestionClassifierNodeType } from '../types'
import type { Topic } from '@/app/components/workflow/nodes/_base/components/branch-list/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
@@ -61,7 +61,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/config-vision', () =>
),
}))
vi.mock('../components/class-list', () => ({
vi.mock('../../_base/components/branch-list/class-list', () => ({
__esModule: true,
default: () => <div>class-list</div>,
}))
@@ -1,7 +1,7 @@
import type { Topic } from '../../types'
import type { Topic } from '@/app/components/workflow/nodes/_base/components/branch-list/types'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ClassItem from '../class-item'
import ClassItem from '../../../_base/components/branch-list/class-item'
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
__esModule: true,
@@ -1,9 +1,9 @@
import type { Topic } from '../../types'
import type { Topic } from '@/app/components/workflow/nodes/_base/components/branch-list/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { useEdgesInteractions } from '../../../../hooks/use-edges-interactions'
import ClassList from '../class-list'
import ClassList from '../../../_base/components/branch-list/class-list'
vi.mock('react-sortablejs', () => ({
__esModule: true,
@@ -18,7 +18,7 @@ vi.mock('../../../../hooks/use-edges-interactions', async (importOriginal) => {
}
})
vi.mock('../class-item', () => ({
vi.mock('../../../_base/components/branch-list/class-item', () => ({
__esModule: true,
default: ({
payload,
@@ -1,223 +0,0 @@
'use client'
import type { FC } from 'react'
import type { Topic } from '@/app/components/workflow/nodes/question-classifier/types'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ReactSortable } from 'react-sortablejs'
import { useKeyboardSortable } from '@/app/components/base/keyboard-sortable/use-keyboard-sortable'
import { useEdgesInteractions } from '../../../hooks/use-edges-interactions'
import AddButton from '../../_base/components/add-button'
import { useInlineLabelHintDismissed } from '../storage'
import Item from './class-item'
import { getDefaultClassLabel, isDefaultClassLabel } from './class-label-utils'
const i18nPrefix = 'nodes.questionClassifiers'
type Props = Readonly<{
nodeId: string
list: Topic[]
onChange: (list: Topic[]) => void
readonly?: boolean
filterVar: (payload: Var, valueSelector: ValueSelector) => boolean
handleSortTopic?: (newTopics: (Topic & { id: string })[]) => void
}>
const ClassList: FC<Props> = ({
nodeId,
list,
onChange,
readonly,
filterVar,
handleSortTopic = noop,
}) => {
const { t } = useTranslation()
const { handleEdgeDeleteByDeleteBranch } = useEdgesInteractions()
const listContainerRef = useRef<HTMLDivElement>(null)
const [shouldScrollToEnd, setShouldScrollToEnd] = useState(false)
const prevListLength = useRef(list.length)
const [collapsed, setCollapsed] = useState(false)
const [storedRenameHintDismissed, setIsRenameHintDismissed] = useInlineLabelHintDismissed()
const isRenameHintDismissed = storedRenameHintDismissed ?? false
const handleClassChange = useCallback(
(index: number) => {
return (value: Topic) => {
const newList = produce(list, (draft) => {
draft[index] = value
})
onChange(newList)
}
},
[list, onChange],
)
const handleAddClass = useCallback(() => {
const newList = produce(list, (draft) => {
draft.push({
id: `${Date.now()}`,
name: '',
label: getDefaultClassLabel(t, draft.length + 1),
})
})
onChange(newList)
setShouldScrollToEnd(true)
if (collapsed) setCollapsed(false)
}, [collapsed, list, onChange, t])
const handleRemoveClass = useCallback(
(index: number) => {
return () => {
handleEdgeDeleteByDeleteBranch(nodeId, list[index]!.id)
const newList = produce(list, (draft) => {
draft.splice(index, 1)
})
onChange(newList)
}
},
[list, onChange, handleEdgeDeleteByDeleteBranch, nodeId],
)
const keyboardSort = useKeyboardSortable({
items: list,
onChange: handleSortTopic,
disabled: readonly,
getItemLabel: (item) => item.label || item.name,
})
const sortableTopics = useMemo(
() => keyboardSort.items.map((item) => ({ ...item })),
[keyboardSort.items],
)
const topicCount = list.length
useEffect(() => {
if (shouldScrollToEnd && list.length > prevListLength.current) setShouldScrollToEnd(false)
prevListLength.current = list.length
}, [list.length, shouldScrollToEnd])
const handleCollapse = useCallback(() => {
setCollapsed(!collapsed)
}, [collapsed])
const dismissRenameHint = useCallback(() => {
if (isRenameHintDismissed) return
setIsRenameHintDismissed(true)
}, [isRenameHintDismissed, setIsRenameHintDismissed])
const shouldShowRenameHint =
!readonly &&
!isRenameHintDismissed &&
list.some((item, index) => {
return isDefaultClassLabel(item.label, index + 1, t)
})
return (
<>
{keyboardSort.announcement}
<div className="mb-2 flex items-center justify-between">
<button
type="button"
className="flex cursor-pointer items-center border-none bg-transparent p-0 text-left text-xs font-semibold text-text-secondary uppercase focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={handleCollapse}
>
{t(($) => $[`${i18nPrefix}.class`], { ns: 'workflow' })}{' '}
<span className="text-text-destructive">*</span>
{list.length > 0 && (
<span
aria-hidden="true"
className={cn(
'i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4',
cn(
'size-4 text-text-quaternary transition-transform duration-200',
collapsed && '-rotate-90',
),
)}
/>
)}
</button>
</div>
{shouldShowRenameHint && (
<div className="mb-2 rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-xs text-text-tertiary">
{t(($) => $[`${i18nPrefix}.renameHint`], { ns: 'workflow' })}
</div>
)}
{!collapsed && (
<div ref={listContainerRef} className="overflow-y-visible pl-3">
<ReactSortable
list={sortableTopics}
setList={(items) => {
if (
!keyboardSort.isSorting &&
items.some((item, index) => item.id !== sortableTopics[index]?.id)
)
handleSortTopic(items)
}}
handle=".handle"
ghostClass="bg-components-panel-bg"
animation={150}
disabled={readonly || keyboardSort.isSorting}
className="space-y-2"
>
{keyboardSort.items.map((item, index) => {
const canDrag = !readonly && topicCount >= 2
return (
<div
key={item.id}
className={cn(
'group relative -ml-3 min-h-10 rounded-[10px] bg-components-panel-bg px-0 py-0',
)}
style={{
// Performance hint for browser
contain: 'layout style paint',
}}
>
<div>
{canDrag && (
<IconButton
{...keyboardSort.getHandleProps(index)}
className="handle pointer-events-none absolute top-1.5 left-0.5 z-10 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus:pointer-events-auto focus:opacity-100 aria-pressed:pointer-events-auto aria-pressed:opacity-100"
>
<span aria-hidden="true" className="i-ri-draggable size-3" />
</IconButton>
)}
<Item
className={cn(canDrag && 'handle')}
headerClassName={cn(
canDrag && 'cursor-grab group-focus-within:pl-5 group-hover:pl-5',
)}
nodeId={nodeId}
key={item.id}
payload={item}
onChange={handleClassChange(keyboardSort.getItemKey(index))}
onRemove={handleRemoveClass(keyboardSort.getItemKey(index))}
index={index + 1}
readonly={readonly}
filterVar={filterVar}
onLabelEditStart={dismissRenameHint}
/>
</div>
</div>
)
})}
</ReactSortable>
</div>
)}
{!readonly && !collapsed && (
<div className="mt-2">
<AddButton
onClick={handleAddClass}
text={t(($) => $[`${i18nPrefix}.addClass`], { ns: 'workflow' })}
/>
</div>
)}
</>
)
}
export default React.memo(ClassList)
@@ -1,67 +1,13 @@
import type { TFunction } from 'i18next'
import type { FC } from 'react'
import type { NodeProps } from 'reactflow'
import type { QuestionClassifierNodeType } from './types'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { ModelSelector } from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { NodeSourceHandle } from '../_base/components/node-handle'
import ReadonlyInputWithSelectVar from '../_base/components/readonly-input-with-select-var'
import { getDisplayClassLabel } from './components/class-label-utils'
const MAX_CLASS_TEXT_LENGTH = 50
type TruncatedClassItemProps = {
topic: { id: string; name: string; label?: string }
index: number
nodeId: string
t: TFunction
}
const TruncatedClassItem: FC<TruncatedClassItemProps> = ({ topic, index, nodeId, t }) => {
const displayLabel = getDisplayClassLabel(topic.label, index + 1, t)
const truncatedText =
topic.name.length > MAX_CLASS_TEXT_LENGTH
? `${topic.name.slice(0, MAX_CLASS_TEXT_LENGTH)}...`
: topic.name
const shouldShowTooltip = topic.name.length > MAX_CLASS_TEXT_LENGTH
const content = (
<div className="truncate system-xs-regular text-text-tertiary">
<ReadonlyInputWithSelectVar value={truncatedText} nodeId={nodeId} className="truncate" />
</div>
)
return (
<div className="flex flex-col gap-y-0.5 rounded-md bg-workflow-block-parma-bg px-1.25 py-0.75">
<div className="text-xs/4 font-semibold text-text-secondary">{displayLabel}</div>
{shouldShowTooltip ? (
<Popover>
<PopoverTrigger
openOnHover
aria-label={topic.name}
className="w-full border-0 bg-transparent p-0 text-left"
>
{content}
</PopoverTrigger>
<PopoverContent className="max-w-75 px-3 py-2 system-xs-regular wrap-break-word text-text-tertiary">
<ReadonlyInputWithSelectVar value={topic.name} nodeId={nodeId} />
</PopoverContent>
</Popover>
) : (
content
)}
</div>
)
}
import { NodeBranches } from '../_base/components/branch-list/node-branches'
const Node: FC<NodeProps<QuestionClassifierNodeType>> = (props) => {
const { t } = useTranslation()
const { data, id } = props
const { data } = props
const { provider, name: modelId } = data.model
// const tempTopics = data.topics
const topics = data.classes
@@ -80,22 +26,7 @@ const Node: FC<NodeProps<QuestionClassifierNodeType>> = (props) => {
disabled
/>
)}
{!!topics.length && (
<div className="mt-2 space-y-0.5">
<div className="space-y-0.5">
{topics.map((topic, index) => (
<div key={topic.id} className="relative">
<TruncatedClassItem topic={topic} index={index} nodeId={id} t={t} />
<NodeSourceHandle
{...props}
handleId={topic.id}
handleClassName="top-1/2! -translate-y-1/2! -right-[21px]!"
/>
</div>
))}
</div>
</div>
)}
{!!topics.length && <NodeBranches node={props} branches={topics} />}
</div>
)
}
@@ -8,10 +8,10 @@ import { FieldCollapse } from '@/app/components/workflow/nodes/_base/components/
import Field from '@/app/components/workflow/nodes/_base/components/field'
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import ClassList from '../_base/components/branch-list/class-list'
import ConfigVision from '../_base/components/config-vision'
import VarReferencePicker from '../_base/components/variable/var-reference-picker'
import AdvancedSetting from './components/advanced-setting'
import ClassList from './components/class-list'
import useConfig from './use-config'
const i18nPrefix = 'nodes.questionClassifiers'
@@ -1,3 +1,4 @@
import type { Topic } from '../_base/components/branch-list/types'
import type {
CommonNodeType,
Memory,
@@ -6,12 +7,6 @@ import type {
VisionSetting,
} from '@/app/components/workflow/types'
export type Topic = {
id: string
name: string
label?: string
}
export type QuestionClassifierNodeType = CommonNodeType & {
query_variable_selector: ValueSelector
model: ModelConfig
@@ -1,5 +1,6 @@
import type { Memory, ValueSelector, Var } from '../../types'
import type { QuestionClassifierNodeType, Topic } from './types'
import type { QuestionClassifierNodeType } from './types'
import type { Topic } from '@/app/components/workflow/nodes/_base/components/branch-list/types'
import { produce } from 'immer'
import { startTransition, useCallback, useEffect, useRef } from 'react'
import { useUpdateNodeInternals } from 'reactflow'
@@ -1,10 +1,7 @@
import type { ElkNode, LayoutOptions } from 'elkjs/lib/elk-api'
import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
import type { CaseItem, IfElseNodeType } from '@/app/components/workflow/nodes/if-else/types'
import type {
QuestionClassifierNodeType,
Topic,
} from '@/app/components/workflow/nodes/question-classifier/types'
import type { QuestionClassifierNodeType } from '@/app/components/workflow/nodes/question-classifier/types'
import type { Edge, Node } from '@/app/components/workflow/types'
import { cloneDeep } from 'es-toolkit/object'
import {
@@ -12,6 +9,7 @@ import {
NODE_LAYOUT_HORIZONTAL_PADDING,
NODE_LAYOUT_VERTICAL_PADDING,
} from '@/app/components/workflow/constants'
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
import { BlockEnum } from '@/app/components/workflow/types'
@@ -303,9 +301,11 @@ const sortQuestionClassifierOutEdges = (classifierNode: Node, outEdges: Edge[]):
const handleB = edgeB.sourceHandle
if (handleA && handleB) {
const classes = (classifierNode.data as QuestionClassifierNodeType).classes || []
const indexA = classes.findIndex((t: Topic) => t.id === handleA)
const indexB = classes.findIndex((t: Topic) => t.id === handleB)
const classes = hasAgentV2OutputRoutes(classifierNode.data)
? (classifierNode.data.agent_output_routes?.routes ?? [])
: (classifierNode.data as QuestionClassifierNodeType).classes || []
const indexA = classes.findIndex((t) => t.id === handleA)
const indexB = classes.findIndex((t) => t.id === handleB)
if (indexA !== -1 && indexB !== -1) return indexA - indexB
}
@@ -382,7 +382,7 @@ const buildPortAwareGraph = (nodes: Node[], edges: Edge[]) => {
let outEdges = outEdgesByNode.get(node.id) || []
if (node.data.type === BlockEnum.IfElse) outEdges = sortIfElseOutEdges(node, outEdges)
else if (node.data.type === BlockEnum.QuestionClassifier)
else if (node.data.type === BlockEnum.QuestionClassifier || hasAgentV2OutputRoutes(node.data))
outEdges = sortQuestionClassifierOutEdges(node, outEdges)
else if (node.data.type === BlockEnum.HumanInput)
outEdges = sortHumanInputOutEdges(node, outEdges)
@@ -6,6 +6,10 @@ import type { ToolNodeType } from '../nodes/tool/types'
import type { Edge, Node } from '../types'
import { cloneDeep } from 'es-toolkit/object'
import { getConnectedEdges } from 'reactflow'
import {
hasAgentV2OutputRoutes,
isAgentV2NodeData,
} from '@/app/components/workflow/nodes/agent-v2/types'
import { correctModelProvider } from '@/utils'
import { getIterationStartNode, getLoopStartNode } from '.'
import {
@@ -259,6 +263,13 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
})
}
if (hasAgentV2OutputRoutes(node.data))
node.data._targetBranches = (node.data.agent_output_routes?.routes ?? []).map((route) => ({
id: route.id,
name: route.name ?? '',
}))
else if (isAgentV2NodeData(node.data)) node.data._targetBranches = []
if (node.data.type === BlockEnum.Iteration) {
const iterationNodeData = node.data as IterationNodeType
iterationNodeData._children = iterationOrLoopNodeMap[node.id] || []
@@ -1,22 +1,15 @@
import type { AgentV2NodeType } from '@/app/components/workflow/nodes/agent-v2/types'
import { render } from '@testing-library/react'
import { ReactFlowProvider } from 'reactflow'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
import { BlockEnum } from '@/app/components/workflow/types'
import CustomNode from '../index'
vi.mock('reactflow', () => ({
Handle: (props: { id: string; type: string; className?: string }) => (
<div
data-testid="handle"
data-handleid={props.id}
data-type={props.type}
className={props.className}
/>
),
Position: {
Left: 'left',
Right: 'right',
},
}))
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock({ 'workflow.nodes.agent.outputRoutes.route': 'Route {{index}}' })
})
describe('workflow preview custom node', () => {
it('renders the mapped node component inside the shared base card', () => {
@@ -38,7 +31,9 @@ describe('workflow preview custom node', () => {
} as never,
}
const { container, getByText } = render(<CustomNode {...props} />)
const { container, getByText } = render(<CustomNode {...props} />, {
wrapper: ReactFlowProvider,
})
expect(getByText('Classifier node')).toBeInTheDocument()
expect(container.querySelector('[data-handleid="class-a"]')).toBeInTheDocument()
@@ -70,10 +65,67 @@ describe('workflow preview custom node', () => {
} as never,
}
const { container, getByText } = render(<CustomNode {...props} />)
const { container, getByText } = render(<CustomNode {...props} />, {
wrapper: ReactFlowProvider,
})
expect(getByText('Human Input')).toBeInTheDocument()
expect(container.querySelector('[data-handleid="approve"]')).toBeInTheDocument()
expect(container.querySelector('[data-handleid="__timeout"]')).toBeInTheDocument()
})
it.each([BlockEnum.Agent, BlockEnum.AgentV2])(
'renders route labels and connections for %s while preserving the failure exit',
(type) => {
const data: AgentV2NodeType = {
type,
title: 'Agent',
desc: '',
agent_node_kind: 'dify_agent',
version: '2',
error_strategy: ErrorHandleTypeEnum.failBranch,
agent_output_routes: {
enabled: true,
routes: [
{ id: 'accepted', name: 'The request is accepted', label: 'Accepted' },
{ id: 'rejected', name: 'The request is rejected' },
],
},
}
const props: React.ComponentProps<typeof CustomNode> = {
id: 'agent-1',
type: 'custom-node',
selected: false,
zIndex: 1,
isConnectable: false,
dragging: false,
xPos: 0,
yPos: 0,
dragHandle: undefined,
data,
}
const { container, getByText, queryByText, rerender } = render(<CustomNode {...props} />, {
wrapper: ReactFlowProvider,
})
// ReactFlow resolves the saved edges through these source handle IDs.
const sourceHandles = () =>
Array.from(container.querySelectorAll('.react-flow__handle.source'), (handle) =>
handle.getAttribute('data-handleid'),
).sort()
expect(getByText('Accepted')).toBeInTheDocument()
expect(getByText('Route 2')).toBeInTheDocument()
expect(sourceHandles()).toEqual(['accepted', 'fail-branch', 'rejected'])
rerender(
<CustomNode
{...props}
data={{ ...data, agent_output_routes: { ...data.agent_output_routes, enabled: false } }}
/>,
)
expect(queryByText('Accepted')).not.toBeInTheDocument()
expect(sourceHandles()).toEqual(['fail-branch', 'source'])
},
)
})
@@ -0,0 +1,36 @@
import type { NodeProps } from '@/app/components/workflow/types'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import InfoPanel from '@/app/components/workflow/nodes/_base/components/info-panel'
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
import { NodeSourceHandle } from '../../node-handle'
const Node = (props: NodeProps) => {
const { data } = props
const { t } = useTranslation()
if (!hasAgentV2OutputRoutes(data)) return null
return (
<div className="mb-1 space-y-0.5 px-3 py-1">
{data.agent_output_routes?.routes?.map((route, index) => (
<div key={route.id} className="relative">
<InfoPanel
title={
route.label?.trim() ||
t(($) => $['nodes.agent.outputRoutes.route'], { ns: 'workflow', index: index + 1 })
}
content=""
/>
<NodeSourceHandle
{...props}
handleId={route.id}
handleClassName="top-1/2! -translate-y-1/2! -right-[21px]!"
/>
</div>
))}
</div>
)
}
export default memo(Node)
@@ -6,6 +6,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop
import { cloneElement, memo } from 'react'
import { useTranslation } from 'react-i18next'
import BlockIcon from '@/app/components/workflow/block-icon'
import { hasAgentV2OutputRoutes } from '@/app/components/workflow/nodes/agent-v2/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { hasErrorHandleNode } from '@/app/components/workflow/utils'
import ErrorHandleOnNode from '../error-handle-on-node'
@@ -56,7 +57,8 @@ const BaseCard = ({ id, data, children }: NodeCardProps) => {
/>
{data.type !== BlockEnum.IfElse &&
data.type !== BlockEnum.QuestionClassifier &&
data.type !== BlockEnum.HumanInput && (
data.type !== BlockEnum.HumanInput &&
!hasAgentV2OutputRoutes(data) && (
<NodeSourceHandle
id={id}
data={data}
@@ -1,4 +1,5 @@
import { BlockEnum } from '@/app/components/workflow/types'
import AgentNode from './agent-v2/node'
import HumanInputNode from './human-input/node'
import IfElseNode from './if-else/node'
import IterationNode from './iteration/node'
@@ -6,6 +7,8 @@ import LoopNode from './loop/node'
import QuestionClassifierNode from './question-classifier/node'
export const NodeComponentMap: Record<string, any> = {
[BlockEnum.Agent]: AgentNode,
[BlockEnum.AgentV2]: AgentNode,
[BlockEnum.QuestionClassifier]: QuestionClassifierNode,
[BlockEnum.IfElse]: IfElseNode,
[BlockEnum.HumanInput]: HumanInputNode,
@@ -3,8 +3,8 @@ import type { NodeProps } from 'reactflow'
import type { QuestionClassifierNodeType } from '@/app/components/workflow/nodes/question-classifier/types'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { getDisplayClassLabel } from '@/app/components/workflow/nodes/_base/components/branch-list/class-label-utils'
import InfoPanel from '@/app/components/workflow/nodes/_base/components/info-panel'
import { getDisplayClassLabel } from '@/app/components/workflow/nodes/question-classifier/components/class-label-utils'
import { NodeSourceHandle } from '../../node-handle'
const Node: FC<NodeProps<QuestionClassifierNodeType>> = (props) => {
+6
View File
@@ -372,6 +372,11 @@
"nodes.agent.model": "النموذج",
"nodes.agent.modelNotInstallTooltip": "هذا النموذج غير مثبت",
"nodes.agent.modelNotSelected": "النموذج غير محدد",
"nodes.agent.outputRoutes.add": "إضافة مسار",
"nodes.agent.outputRoutes.placeholder": "صف متى يجب على الوكيل اختيار هذا المسار",
"nodes.agent.outputRoutes.renameHint": "انقر مرتين على عنوان المسار لإعادة تسميته",
"nodes.agent.outputRoutes.route": "المسار {{index}}",
"nodes.agent.outputRoutes.title": "مسارات الإخراج",
"nodes.agent.outputVars.confirm": "تأكيد",
"nodes.agent.outputVars.defaultValueArrayInvalid": "أدخل مصفوفة JSON.",
"nodes.agent.outputVars.defaultValueBooleanInvalid": "أدخل true أو false.",
@@ -394,6 +399,7 @@
"nodes.agent.outputVars.newOutput": "إخراج جديد",
"nodes.agent.outputVars.requiredLabel": "مطلوب",
"nodes.agent.outputVars.showAdvancedOptions": "إظهار الخيارات المتقدمة",
"nodes.agent.outputVars.switch": "معرّف مسار الإخراج المحدد.",
"nodes.agent.outputVars.text": "محتوى تم إنشاؤه بواسطة الوكيل",
"nodes.agent.outputVars.typeLabel": "نوع الإخراج",
"nodes.agent.outputVars.usage": "معلومات استخدام النموذج",
+6
View File
@@ -372,6 +372,11 @@
"nodes.agent.model": "Model",
"nodes.agent.modelNotInstallTooltip": "Bu model quraşdırılmayıb",
"nodes.agent.modelNotSelected": "Model seçilməyib",
"nodes.agent.outputRoutes.add": "Marşrut əlavə et",
"nodes.agent.outputRoutes.placeholder": "Agentin bu marşrutu nə vaxt seçməli olduğunu təsvir edin",
"nodes.agent.outputRoutes.renameHint": "Adını dəyişmək üçün marşrut başlığına iki dəfə klikləyin",
"nodes.agent.outputRoutes.route": "Marşrut {{index}}",
"nodes.agent.outputRoutes.title": "Çıxış marşrutları",
"nodes.agent.outputVars.confirm": "Təsdiqlə",
"nodes.agent.outputVars.defaultValueArrayInvalid": "JSON massivi daxil edin.",
"nodes.agent.outputVars.defaultValueBooleanInvalid": "true və ya false daxil edin.",
@@ -394,6 +399,7 @@
"nodes.agent.outputVars.newOutput": "Yeni çıxış",
"nodes.agent.outputVars.requiredLabel": "Məcburi",
"nodes.agent.outputVars.showAdvancedOptions": "Əlavə seçimləri göstər",
"nodes.agent.outputVars.switch": "Seçilmiş çıxış marşrutunun identifikatoru.",
"nodes.agent.outputVars.text": "Agentin yaratdığı məzmun",
"nodes.agent.outputVars.typeLabel": "Çıxış növü",
"nodes.agent.outputVars.usage": "Model istifadəsi haqqında məlumat",
+6
View File
@@ -372,6 +372,11 @@
"nodes.agent.model": "Modell",
"nodes.agent.modelNotInstallTooltip": "Dieses Modell ist nicht installiert",
"nodes.agent.modelNotSelected": "Modell nicht ausgewählt",
"nodes.agent.outputRoutes.add": "Route hinzufügen",
"nodes.agent.outputRoutes.placeholder": "Beschreiben Sie, wann der Agent diese Route wählen soll",
"nodes.agent.outputRoutes.renameHint": "Zum Umbenennen auf den Routentitel doppelklicken",
"nodes.agent.outputRoutes.route": "Route {{index}}",
"nodes.agent.outputRoutes.title": "Ausgaberouten",
"nodes.agent.outputVars.confirm": "Bestätigen",
"nodes.agent.outputVars.defaultValueArrayInvalid": "Geben Sie ein JSON-Array ein.",
"nodes.agent.outputVars.defaultValueBooleanInvalid": "Geben Sie true oder false ein.",
@@ -394,6 +399,7 @@
"nodes.agent.outputVars.newOutput": "Neue Ausgabe",
"nodes.agent.outputVars.requiredLabel": "Erforderlich",
"nodes.agent.outputVars.showAdvancedOptions": "Erweiterte Optionen anzeigen",
"nodes.agent.outputVars.switch": "ID der ausgewählten Ausgaberoute.",
"nodes.agent.outputVars.text": "Von Agenten generierte Inhalte",
"nodes.agent.outputVars.typeLabel": "Ausgabetyp",
"nodes.agent.outputVars.usage": "Nutzungsinformationen des Modells",
+6
View File
@@ -372,6 +372,11 @@
"nodes.agent.model": "model",
"nodes.agent.modelNotInstallTooltip": "This model is not installed",
"nodes.agent.modelNotSelected": "Model not selected",
"nodes.agent.outputRoutes.add": "Add Route",
"nodes.agent.outputRoutes.placeholder": "Describe when the Agent should choose this route",
"nodes.agent.outputRoutes.renameHint": "Double-click the route title to rename",
"nodes.agent.outputRoutes.route": "Route {{index}}",
"nodes.agent.outputRoutes.title": "Output Routes",
"nodes.agent.outputVars.confirm": "Confirm",
"nodes.agent.outputVars.defaultValueArrayInvalid": "Enter a JSON array.",
"nodes.agent.outputVars.defaultValueBooleanInvalid": "Enter true or false.",
@@ -394,6 +399,7 @@
"nodes.agent.outputVars.newOutput": "New output",
"nodes.agent.outputVars.requiredLabel": "Required",
"nodes.agent.outputVars.showAdvancedOptions": "Show advanced options",
"nodes.agent.outputVars.switch": "Selected output route ID.",
"nodes.agent.outputVars.text": "agent generated content",
"nodes.agent.outputVars.typeLabel": "Output type",
"nodes.agent.outputVars.usage": "Model Usage Information",
+6
View File
@@ -372,6 +372,11 @@
"nodes.agent.model": "modelo",
"nodes.agent.modelNotInstallTooltip": "Este modelo no está instalado",
"nodes.agent.modelNotSelected": "Modelo no seleccionado",
"nodes.agent.outputRoutes.add": "Añadir ruta",
"nodes.agent.outputRoutes.placeholder": "Describe cuándo el agente debe elegir esta ruta",
"nodes.agent.outputRoutes.renameHint": "Haz doble clic en el título de la ruta para cambiar el nombre",
"nodes.agent.outputRoutes.route": "Ruta {{index}}",
"nodes.agent.outputRoutes.title": "Rutas de salida",
"nodes.agent.outputVars.confirm": "Confirmar",
"nodes.agent.outputVars.defaultValueArrayInvalid": "Introduce un arreglo JSON.",
"nodes.agent.outputVars.defaultValueBooleanInvalid": "Introduce true o false.",
@@ -394,6 +399,7 @@
"nodes.agent.outputVars.newOutput": "Nueva salida",
"nodes.agent.outputVars.requiredLabel": "Obligatorio",
"nodes.agent.outputVars.showAdvancedOptions": "Mostrar opciones avanzadas",
"nodes.agent.outputVars.switch": "ID de la ruta de salida seleccionada.",
"nodes.agent.outputVars.text": "Contenido generado por el agente",
"nodes.agent.outputVars.typeLabel": "Tipo de salida",
"nodes.agent.outputVars.usage": "Información de uso del modelo",
+6
View File
@@ -372,6 +372,11 @@
"nodes.agent.model": "مدل",
"nodes.agent.modelNotInstallTooltip": "این مدل نصب نشده است",
"nodes.agent.modelNotSelected": "مدل انتخاب نشده است",
"nodes.agent.outputRoutes.add": "افزودن مسیر",
"nodes.agent.outputRoutes.placeholder": "توضیح دهید عامل چه زمانی باید این مسیر را انتخاب کند",
"nodes.agent.outputRoutes.renameHint": "برای تغییر نام، روی عنوان مسیر دوبار کلیک کنید",
"nodes.agent.outputRoutes.route": "مسیر {{index}}",
"nodes.agent.outputRoutes.title": "مسیرهای خروجی",
"nodes.agent.outputVars.confirm": "تأیید",
"nodes.agent.outputVars.defaultValueArrayInvalid": "یک آرایه JSON وارد کنید.",
"nodes.agent.outputVars.defaultValueBooleanInvalid": "true یا false وارد کنید.",
@@ -394,6 +399,7 @@
"nodes.agent.outputVars.newOutput": "خروجی جدید",
"nodes.agent.outputVars.requiredLabel": "الزامی",
"nodes.agent.outputVars.showAdvancedOptions": "نمایش گزینه‌های پیشرفته",
"nodes.agent.outputVars.switch": "شناسهٔ مسیر خروجی انتخاب‌شده.",
"nodes.agent.outputVars.text": "محتوای متنی تولیدشده توسط عامل",
"nodes.agent.outputVars.typeLabel": "نوع خروجی",
"nodes.agent.outputVars.usage": "اطلاعات مصرف مدل",
+6
View File
@@ -372,6 +372,11 @@
"nodes.agent.model": "modèle",
"nodes.agent.modelNotInstallTooltip": "Ce modèle n’est pas installé",
"nodes.agent.modelNotSelected": "Modèle non sélectionné",
"nodes.agent.outputRoutes.add": "Ajouter une route",
"nodes.agent.outputRoutes.placeholder": "Décrivez quand l’agent doit choisir cette route",
"nodes.agent.outputRoutes.renameHint": "Double-cliquez sur le titre de la route pour la renommer",
"nodes.agent.outputRoutes.route": "Route {{index}}",
"nodes.agent.outputRoutes.title": "Routes de sortie",
"nodes.agent.outputVars.confirm": "Confirmer",
"nodes.agent.outputVars.defaultValueArrayInvalid": "Saisissez un tableau JSON.",
"nodes.agent.outputVars.defaultValueBooleanInvalid": "Saisissez true ou false.",
@@ -394,6 +399,7 @@
"nodes.agent.outputVars.newOutput": "Nouvelle sortie",
"nodes.agent.outputVars.requiredLabel": "Requis",
"nodes.agent.outputVars.showAdvancedOptions": "Afficher les options avancées",
"nodes.agent.outputVars.switch": "Identifiant de la route de sortie sélectionnée.",
"nodes.agent.outputVars.text": "Contenu généré par l’agent",
"nodes.agent.outputVars.typeLabel": "Type de sortie",
"nodes.agent.outputVars.usage": "Informations sur l’utilisation du modèle",
+6
View File
@@ -372,6 +372,11 @@
"nodes.agent.model": "मॉडल",
"nodes.agent.modelNotInstallTooltip": "यह मॉडल स्थापित नहीं है",
"nodes.agent.modelNotSelected": "मॉडल का चयन नहीं किया गया",
"nodes.agent.outputRoutes.add": "मार्ग जोड़ें",
"nodes.agent.outputRoutes.placeholder": "बताएँ कि एजेंट को यह मार्ग कब चुनना चाहिए",
"nodes.agent.outputRoutes.renameHint": "नाम बदलने के लिए मार्ग शीर्षक पर डबल क्लिक करें",
"nodes.agent.outputRoutes.route": "मार्ग {{index}}",
"nodes.agent.outputRoutes.title": "आउटपुट मार्ग",
"nodes.agent.outputVars.confirm": "पुष्टि करें",
"nodes.agent.outputVars.defaultValueArrayInvalid": "एक JSON सरणी दर्ज करें।",
"nodes.agent.outputVars.defaultValueBooleanInvalid": "true या false दर्ज करें।",
@@ -394,6 +399,7 @@
"nodes.agent.outputVars.newOutput": "नया आउटपुट",
"nodes.agent.outputVars.requiredLabel": "आवश्यक",
"nodes.agent.outputVars.showAdvancedOptions": "उन्नत विकल्प दिखाएँ",
"nodes.agent.outputVars.switch": "चुने गए आउटपुट रूट की आईडी।",
"nodes.agent.outputVars.text": "एजेंट द्वारा उत्पन्न सामग्री",
"nodes.agent.outputVars.typeLabel": "आउटपुट प्रकार",
"nodes.agent.outputVars.usage": "मॉडल उपयोग जानकारी",
+6
View File
@@ -372,6 +372,11 @@
"nodes.agent.model": "pola",
"nodes.agent.modelNotInstallTooltip": "Model ini tidak dipasang",
"nodes.agent.modelNotSelected": "Model tidak dipilih",
"nodes.agent.outputRoutes.add": "Tambah rute",
"nodes.agent.outputRoutes.placeholder": "Jelaskan kapan agen harus memilih rute ini",
"nodes.agent.outputRoutes.renameHint": "Klik dua kali judul rute untuk mengganti nama",
"nodes.agent.outputRoutes.route": "Rute {{index}}",
"nodes.agent.outputRoutes.title": "Rute keluaran",
"nodes.agent.outputVars.confirm": "Konfirmasi",
"nodes.agent.outputVars.defaultValueArrayInvalid": "Masukkan array JSON.",
"nodes.agent.outputVars.defaultValueBooleanInvalid": "Masukkan true atau false.",
@@ -394,6 +399,7 @@
"nodes.agent.outputVars.newOutput": "Output baru",
"nodes.agent.outputVars.requiredLabel": "Wajib",
"nodes.agent.outputVars.showAdvancedOptions": "Tampilkan opsi lanjutan",
"nodes.agent.outputVars.switch": "ID rute keluaran yang dipilih.",
"nodes.agent.outputVars.text": "Konten yang dihasilkan agen",
"nodes.agent.outputVars.typeLabel": "Tipe output",
"nodes.agent.outputVars.usage": "Informasi Penggunaan Model",
+6
View File
@@ -372,6 +372,11 @@
"nodes.agent.model": "modello",
"nodes.agent.modelNotInstallTooltip": "Questo modello non è installato",
"nodes.agent.modelNotSelected": "Modello non selezionato",
"nodes.agent.outputRoutes.add": "Aggiungi percorso",
"nodes.agent.outputRoutes.placeholder": "Descrivi quando l’agente deve scegliere questo percorso",
"nodes.agent.outputRoutes.renameHint": "Fai doppio clic sul titolo del percorso per rinominarlo",
"nodes.agent.outputRoutes.route": "Percorso {{index}}",
"nodes.agent.outputRoutes.title": "Percorsi di uscita",
"nodes.agent.outputVars.confirm": "Conferma",
"nodes.agent.outputVars.defaultValueArrayInvalid": "Inserisci un array JSON.",
"nodes.agent.outputVars.defaultValueBooleanInvalid": "Inserisci true o false.",
@@ -394,6 +399,7 @@
"nodes.agent.outputVars.newOutput": "Nuovo output",
"nodes.agent.outputVars.requiredLabel": "Obbligatorio",
"nodes.agent.outputVars.showAdvancedOptions": "Mostra opzioni avanzate",
"nodes.agent.outputVars.switch": "ID del percorso di output selezionato.",
"nodes.agent.outputVars.text": "Contenuto generato dall'agente",
"nodes.agent.outputVars.typeLabel": "Tipo di output",
"nodes.agent.outputVars.usage": "Informazioni sull'utilizzo del modello",
+6
View File
@@ -372,6 +372,11 @@
"nodes.agent.model": "モデル",
"nodes.agent.modelNotInstallTooltip": "このモデルはインストールされていません",
"nodes.agent.modelNotSelected": "モデルが選択されていません",
"nodes.agent.outputRoutes.add": "ルートを追加",
"nodes.agent.outputRoutes.placeholder": "エージェントがこのルートを選択する条件を記述",
"nodes.agent.outputRoutes.renameHint": "ルート名をダブルクリックして変更",
"nodes.agent.outputRoutes.route": "ルート {{index}}",
"nodes.agent.outputRoutes.title": "出力ルート",
"nodes.agent.outputVars.confirm": "確認",
"nodes.agent.outputVars.defaultValueArrayInvalid": "JSON 配列を入力してください。",
"nodes.agent.outputVars.defaultValueBooleanInvalid": "true または false を入力してください。",
@@ -394,6 +399,7 @@
"nodes.agent.outputVars.newOutput": "新しい出力",
"nodes.agent.outputVars.requiredLabel": "必須",
"nodes.agent.outputVars.showAdvancedOptions": "詳細オプションを表示",
"nodes.agent.outputVars.switch": "選択された出力ルートの ID。",
"nodes.agent.outputVars.text": "エージェント生成コンテンツ",
"nodes.agent.outputVars.typeLabel": "出力タイプ",
"nodes.agent.outputVars.usage": "モデル使用量",
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more