feat: wire workflow agent node runtime (#36437)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123
2026-05-20 12:39:45 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent 56d4d54c16
commit 60cd346fa6
27 changed files with 2626 additions and 7 deletions
+31 -6
View File
@@ -37,6 +37,10 @@ from core.workflow.nodes.agent.plugin_strategy_adapter import (
PluginAgentStrategyResolver,
)
from core.workflow.nodes.agent.runtime_support import AgentRuntimeSupport
from core.workflow.nodes.agent_v2 import DifyAgentNode
from core.workflow.nodes.agent_v2.binding_resolver import WorkflowAgentBindingResolver
from core.workflow.nodes.agent_v2.output_adapter import WorkflowAgentOutputAdapter
from core.workflow.nodes.agent_v2.runtime_request_builder import WorkflowAgentRuntimeRequestBuilder
from core.workflow.system_variables import SystemVariableKey, get_system_text, system_variable_selector
from core.workflow.template_rendering import CodeExecutorJinja2TemplateRenderer
from graphon.entities.base_node_data import BaseNodeData
@@ -438,12 +442,7 @@ class DifyNodeFactory(NodeFactory):
"tool_file_manager": self._bound_tool_file_manager_factory(),
"runtime": self._tool_runtime,
},
BuiltinNodeTypes.AGENT: lambda: {
"strategy_resolver": self._agent_strategy_resolver,
"presentation_provider": self._agent_strategy_presentation_provider,
"runtime_support": self._agent_runtime_support,
"message_transformer": self._agent_message_transformer,
},
BuiltinNodeTypes.AGENT: lambda: self._build_agent_node_init_kwargs(node_class=node_class),
}
node_init_kwargs = node_init_kwargs_factories.get(node_type, lambda: {})()
constructor_node_data = resolved_node_data.model_dump(mode="python", by_alias=True)
@@ -469,6 +468,32 @@ class DifyNodeFactory(NodeFactory):
def _resolve_node_class(*, node_type: NodeType, node_version: str) -> type[Node]:
return resolve_workflow_node_class(node_type=node_type, node_version=node_version)
def _build_agent_node_init_kwargs(self, *, node_class: type[Node]) -> dict[str, object]:
if issubclass(node_class, DifyAgentNode):
from clients.agent_backend import AgentBackendRunEventAdapter, AgentBackendRunRequestBuilder
from clients.agent_backend.factory import create_agent_backend_run_client
return {
"binding_resolver": WorkflowAgentBindingResolver(),
"runtime_request_builder": WorkflowAgentRuntimeRequestBuilder(
credentials_provider=self._llm_credentials_provider,
request_builder=AgentBackendRunRequestBuilder(),
),
"agent_backend_client": create_agent_backend_run_client(
base_url=dify_config.AGENT_BACKEND_BASE_URL,
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
),
"event_adapter": AgentBackendRunEventAdapter(),
"output_adapter": WorkflowAgentOutputAdapter(),
}
return {
"strategy_resolver": self._agent_strategy_resolver,
"presentation_provider": self._agent_strategy_presentation_provider,
"runtime_support": self._agent_runtime_support,
"message_transformer": self._agent_message_transformer,
}
def _build_llm_compatible_node_init_kwargs(
self,
*,
@@ -0,0 +1,4 @@
from .agent_node import DifyAgentNode
from .entities import DifyAgentNodeData
__all__ = ["DifyAgentNode", "DifyAgentNodeData"]
@@ -0,0 +1,281 @@
from __future__ import annotations
from collections.abc import Generator, Mapping, Sequence
from typing import TYPE_CHECKING, Any
from clients.agent_backend import (
AgentBackendError,
AgentBackendHTTPError,
AgentBackendInternalEventType,
AgentBackendRunClient,
AgentBackendRunEventAdapter,
AgentBackendRunFailedInternalEvent,
AgentBackendRunSucceededInternalEvent,
AgentBackendStreamError,
AgentBackendStreamInternalEvent,
AgentBackendTransportError,
AgentBackendValidationError,
)
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
from graphon.node_events import NodeEventBase, NodeRunResult, StreamCompletedEvent
from graphon.nodes.base.node import Node
from .binding_resolver import WorkflowAgentBindingError, WorkflowAgentBindingResolver
from .entities import DifyAgentNodeData
from .output_adapter import WorkflowAgentOutputAdapter
from .runtime_request_builder import (
WorkflowAgentRuntimeBuildContext,
WorkflowAgentRuntimeRequestBuilder,
WorkflowAgentRuntimeRequestBuildError,
)
if TYPE_CHECKING:
from graphon.entities import GraphInitParams
from graphon.runtime import GraphRuntimeState
class DifyAgentNode(Node[DifyAgentNodeData]):
node_type = BuiltinNodeTypes.AGENT
def __init__(
self,
node_id: str,
data: DifyAgentNodeData,
*,
graph_init_params: GraphInitParams,
graph_runtime_state: GraphRuntimeState,
binding_resolver: WorkflowAgentBindingResolver,
runtime_request_builder: WorkflowAgentRuntimeRequestBuilder,
agent_backend_client: AgentBackendRunClient,
event_adapter: AgentBackendRunEventAdapter,
output_adapter: WorkflowAgentOutputAdapter,
) -> None:
super().__init__(
node_id=node_id,
data=data,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
)
self._binding_resolver = binding_resolver
self._runtime_request_builder = runtime_request_builder
self._agent_backend_client = agent_backend_client
self._event_adapter = event_adapter
self._output_adapter = output_adapter
@classmethod
def version(cls) -> str:
return "2"
def populate_start_event(self, event) -> None:
event.extras["agent_node"] = {"version": "2", "agent_node_kind": self.node_data.agent_node_kind}
def _run(self) -> Generator[NodeEventBase, None, None]:
dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
workflow_id = self.graph_init_params.workflow_id
workflow_run_id = get_system_text(
self.graph_runtime_state.variable_pool,
SystemVariableKey.WORKFLOW_EXECUTION_ID,
)
inputs: dict[str, Any] = {}
process_data: dict[str, Any] = {}
metadata: dict[str, Any] = {
"agent_backend": {
"status": "not_started",
}
}
try:
bundle = self._binding_resolver.resolve(
tenant_id=dify_ctx.tenant_id,
app_id=dify_ctx.app_id,
workflow_id=workflow_id,
node_id=self._node_id,
)
runtime_request = self._runtime_request_builder.build(
WorkflowAgentRuntimeBuildContext(
dify_context=dify_ctx,
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
node_id=self._node_id,
node_execution_id=self.id,
variable_pool=self.graph_runtime_state.variable_pool,
binding=bundle.binding,
agent=bundle.agent,
snapshot=bundle.snapshot,
)
)
inputs = {"agent_backend_request": runtime_request.redacted_request}
metadata = dict(runtime_request.metadata)
process_data = {
"agent_id": bundle.agent.id,
"agent_config_snapshot_id": bundle.snapshot.id,
"binding_id": bundle.binding.id,
}
create_response = self._agent_backend_client.create_run(runtime_request.request)
metadata["agent_backend"] = {
**dict(metadata.get("agent_backend") or {}),
"run_id": create_response.run_id,
"status": create_response.status,
}
except WorkflowAgentBindingError as error:
yield self._failure_event(
inputs=inputs,
process_data=process_data,
metadata=metadata,
error=str(error),
error_type=error.error_code,
)
return
except WorkflowAgentRuntimeRequestBuildError as error:
yield self._failure_event(
inputs=inputs,
process_data=process_data,
metadata=metadata,
error=str(error),
error_type=error.error_code,
)
return
except AgentBackendError as error:
yield self._failure_event(
inputs=inputs,
process_data=process_data,
metadata=metadata,
error=str(error),
error_type=self._agent_backend_error_type(error),
)
return
except Exception as error:
yield self._failure_event(
inputs=inputs,
process_data=process_data,
metadata=metadata,
error=str(error),
error_type="agent_workflow_node_runtime_error",
)
return
stream_event_count = 0
try:
for public_event in self._agent_backend_client.stream_events(create_response.run_id):
stream_event_count += 1
for internal_event in self._event_adapter.adapt(public_event):
if internal_event.type == AgentBackendInternalEventType.RUN_STARTED:
continue
if internal_event.type == AgentBackendInternalEventType.STREAM_EVENT:
if isinstance(internal_event, AgentBackendStreamInternalEvent):
self._record_stream_metadata(metadata, internal_event)
continue
metadata["agent_backend"] = {
**dict(metadata.get("agent_backend") or {}),
"stream_event_count": stream_event_count,
}
if isinstance(internal_event, AgentBackendRunSucceededInternalEvent):
yield StreamCompletedEvent(
node_run_result=self._output_adapter.build_success_result(
event=internal_event,
inputs=inputs,
process_data=process_data,
metadata=metadata,
)
)
return
if isinstance(
internal_event,
AgentBackendRunFailedInternalEvent,
) or internal_event.type in {
AgentBackendInternalEventType.RUN_CANCELLED,
AgentBackendInternalEventType.RUN_PAUSED,
}:
yield StreamCompletedEvent(
node_run_result=self._output_adapter.build_failure_result(
event=internal_event,
inputs=inputs,
process_data=process_data,
metadata=metadata,
)
)
return
except AgentBackendError as error:
yield self._failure_event(
inputs=inputs,
process_data=process_data,
metadata=metadata,
error=str(error),
error_type=self._agent_backend_error_type(error),
)
return
except Exception as error:
yield self._failure_event(
inputs=inputs,
process_data=process_data,
metadata=metadata,
error=str(error),
error_type="agent_backend_stream_error",
)
return
yield StreamCompletedEvent(
node_run_result=self._output_adapter.build_stream_exhausted_result(
inputs=inputs,
process_data=process_data,
metadata=metadata,
)
)
@staticmethod
def _failure_event(
*,
inputs: dict[str, Any],
process_data: dict[str, Any],
metadata: dict[str, Any],
error: str,
error_type: str,
) -> StreamCompletedEvent:
return StreamCompletedEvent(
node_run_result=NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
inputs=inputs,
process_data=process_data,
metadata={WorkflowNodeExecutionMetadataKey.AGENT_LOG: metadata},
outputs={},
error=error,
error_type=error_type,
)
)
@staticmethod
def _agent_backend_error_type(error: AgentBackendError) -> str:
if isinstance(error, AgentBackendValidationError):
return "agent_backend_validation_error"
if isinstance(error, AgentBackendHTTPError):
return "agent_backend_http_error"
if isinstance(error, AgentBackendStreamError):
return "agent_backend_stream_error"
if isinstance(error, AgentBackendTransportError):
return "agent_backend_transport_error"
return "agent_backend_error"
@staticmethod
def _record_stream_metadata(metadata: dict[str, Any], event: AgentBackendStreamInternalEvent) -> None:
agent_backend = dict(metadata.get("agent_backend") or {})
agent_backend["last_stream_event_id"] = event.source_event_id
if event.event_kind:
agent_backend["last_stream_event_kind"] = event.event_kind
if isinstance(event.data, Mapping):
usage = event.data.get("usage") or event.data.get("model_usage")
if isinstance(usage, Mapping):
agent_backend["usage"] = dict(usage)
metadata["agent_backend"] = agent_backend
@classmethod
def _extract_variable_selector_to_variable_mapping(
cls,
*,
graph_config: Mapping[str, Any],
node_id: str,
node_data: DifyAgentNodeData,
) -> Mapping[str, Sequence[str]]:
del graph_config, node_id, node_data
return {}
@@ -0,0 +1,93 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from core.db.session_factory import session_factory
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding
class WorkflowAgentBindingError(Exception):
error_code: str
def __init__(self, error_code: str, message: str) -> None:
self.error_code = error_code
super().__init__(message)
@dataclass(frozen=True, slots=True)
class WorkflowAgentBindingBundle:
binding: WorkflowAgentNodeBinding
agent: Agent
snapshot: AgentConfigSnapshot
class WorkflowAgentBindingResolver:
"""Resolve the Agent binding owned by the current workflow id and node id."""
def resolve(
self,
*,
tenant_id: str,
app_id: str,
workflow_id: str,
node_id: str,
) -> WorkflowAgentBindingBundle:
with session_factory.create_session() as session:
binding = session.scalar(
select(WorkflowAgentNodeBinding)
.where(
WorkflowAgentNodeBinding.tenant_id == tenant_id,
WorkflowAgentNodeBinding.app_id == app_id,
WorkflowAgentNodeBinding.workflow_id == workflow_id,
WorkflowAgentNodeBinding.node_id == node_id,
)
.limit(1)
)
if binding is None:
raise WorkflowAgentBindingError(
"agent_binding_not_found",
f"Workflow Agent binding not found for node {node_id}.",
)
if binding.agent_id is None:
raise WorkflowAgentBindingError("agent_not_available", "Workflow Agent binding has no agent.")
if binding.current_snapshot_id is None:
raise WorkflowAgentBindingError(
"agent_config_snapshot_not_found",
"Workflow Agent binding has no current config snapshot.",
)
agent = session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
Agent.id == binding.agent_id,
)
.limit(1)
)
if agent is None or agent.status == AgentStatus.ARCHIVED:
raise WorkflowAgentBindingError(
"agent_not_available",
f"Agent {binding.agent_id} is not available.",
)
snapshot = session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == binding.current_snapshot_id,
)
.limit(1)
)
if snapshot is None:
raise WorkflowAgentBindingError(
"agent_config_snapshot_not_found",
f"Agent config snapshot {binding.current_snapshot_id} not found.",
)
session.expunge(binding)
session.expunge(agent)
session.expunge(snapshot)
return WorkflowAgentBindingBundle(binding=binding, agent=agent, snapshot=snapshot)
@@ -0,0 +1,17 @@
from typing import Literal
from pydantic import model_validator
from graphon.entities.base_node_data import BaseNodeData
from graphon.enums import BuiltinNodeTypes, NodeType
class DifyAgentNodeData(BaseNodeData):
type: NodeType = BuiltinNodeTypes.AGENT
agent_node_kind: Literal["dify_agent"] = "dify_agent"
@model_validator(mode="after")
def validate_version(self) -> "DifyAgentNodeData":
if self.version != "2":
raise ValueError("Dify Agent Node v2 requires version='2'")
return self
@@ -0,0 +1,255 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from clients.agent_backend import (
AgentBackendInternalEvent,
AgentBackendInternalEventType,
AgentBackendRunCancelledInternalEvent,
AgentBackendRunFailedInternalEvent,
AgentBackendRunPausedInternalEvent,
AgentBackendRunSucceededInternalEvent,
)
from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
from graphon.file import File, FileTransferMethod, FileType
from graphon.model_runtime.entities.llm_entities import LLMUsage
from graphon.node_events import NodeRunResult
from graphon.variables.segments import ArrayFileSegment, FileSegment
class WorkflowAgentOutputAdapter:
"""Convert terminal Agent backend events into workflow node run results."""
def build_success_result(
self,
*,
event: AgentBackendRunSucceededInternalEvent,
inputs: dict[str, Any],
process_data: dict[str, Any],
metadata: dict[str, Any],
) -> NodeRunResult:
metadata = self._with_terminal_metadata(metadata, event, "succeeded")
usage = self._usage_from_metadata(metadata)
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
inputs=inputs,
process_data=process_data,
outputs=self._normalize_outputs(event.output),
metadata=self._build_node_metadata(metadata=metadata, usage=usage),
llm_usage=usage or LLMUsage.empty_usage(),
)
def build_failure_result(
self,
*,
event: (
AgentBackendRunFailedInternalEvent
| AgentBackendRunCancelledInternalEvent
| AgentBackendRunPausedInternalEvent
),
inputs: dict[str, Any],
process_data: dict[str, Any],
metadata: dict[str, Any],
) -> NodeRunResult:
status = WorkflowNodeExecutionStatus.FAILED
error = "Agent backend run failed."
error_type = "agent_backend_run_failed"
terminal_status = "failed"
match event:
case AgentBackendRunFailedInternalEvent():
error = event.error
error_type = event.reason or "agent_backend_run_failed"
terminal_status = "failed"
case AgentBackendRunCancelledInternalEvent():
error = event.message or "Agent backend run was cancelled."
error_type = "agent_backend_run_cancelled"
terminal_status = "cancelled"
case AgentBackendRunPausedInternalEvent():
error = event.message or "Agent backend run paused, but workflow Agent Node pause is not supported yet."
error_type = "agent_backend_paused_unsupported"
terminal_status = "paused"
metadata = self._with_terminal_metadata(metadata, event, terminal_status)
usage = self._usage_from_metadata(metadata)
return NodeRunResult(
status=status,
inputs=inputs,
process_data=process_data,
metadata=self._build_node_metadata(metadata=metadata, usage=usage),
llm_usage=usage or LLMUsage.empty_usage(),
error=error,
error_type=error_type,
)
def build_stream_exhausted_result(
self,
*,
inputs: dict[str, Any],
process_data: dict[str, Any],
metadata: dict[str, Any],
) -> NodeRunResult:
usage = self._usage_from_metadata(metadata)
return NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
inputs=inputs,
process_data=process_data,
metadata=self._build_node_metadata(metadata=metadata, usage=usage),
llm_usage=usage or LLMUsage.empty_usage(),
error="Agent backend stream ended before a terminal event.",
error_type="agent_backend_stream_error",
)
@classmethod
def _normalize_outputs(cls, output: Any) -> dict[str, Any]:
if isinstance(output, dict):
if cls._is_file_payload(output):
return {"file": cls._file_segment_from_payload(output)}
return {key: cls._normalize_output_value(value) for key, value in output.items()}
if isinstance(output, str):
return {"text": output}
return {"result": output}
@classmethod
def _normalize_output_value(cls, value: Any) -> Any:
if isinstance(value, File | FileSegment | ArrayFileSegment):
return value
if isinstance(value, Mapping):
if cls._is_file_payload(value):
return cls._file_segment_from_payload(value)
return {key: cls._normalize_output_value(item) for key, item in value.items()}
if isinstance(value, list):
if value and all(isinstance(item, Mapping) and cls._is_file_payload(item) for item in value):
return ArrayFileSegment(value=[cls._file_from_payload(item) for item in value])
return [cls._normalize_output_value(item) for item in value]
return value
@staticmethod
def _is_file_payload(value: Mapping[str, Any]) -> bool:
return any(value.get(key) for key in ("file_id", "upload_file_id", "tool_file_id", "url", "remote_url")) and (
"filename" in value or "mime_type" in value or "url" in value or "remote_url" in value
)
@classmethod
def _file_segment_from_payload(cls, value: Mapping[str, Any]) -> FileSegment:
return FileSegment(value=cls._file_from_payload(value))
@classmethod
def _file_from_payload(cls, value: Mapping[str, Any]) -> File:
remote_url = cls._string_value(value.get("remote_url") or value.get("url"))
upload_file_id = cls._string_value(value.get("upload_file_id") or value.get("file_id"))
tool_file_id = cls._string_value(value.get("tool_file_id"))
filename = cls._string_value(value.get("filename") or value.get("name"))
mime_type = cls._string_value(value.get("mime_type") or value.get("mimetype"))
extension = cls._extension_from_payload(value, filename)
file_type = cls._file_type_from_payload(value, mime_type)
size = value.get("size")
if not isinstance(size, int):
size = -1
if tool_file_id:
transfer_method = FileTransferMethod.TOOL_FILE
related_id = tool_file_id
elif remote_url:
transfer_method = FileTransferMethod.REMOTE_URL
related_id = None
else:
transfer_method = FileTransferMethod.LOCAL_FILE
related_id = upload_file_id
return File(
type=file_type,
transfer_method=transfer_method,
remote_url=remote_url if transfer_method == FileTransferMethod.REMOTE_URL else None,
related_id=related_id,
filename=filename,
extension=extension,
mime_type=mime_type,
size=size,
)
@staticmethod
def _string_value(value: Any) -> str | None:
return value if isinstance(value, str) and value else None
@classmethod
def _extension_from_payload(cls, value: Mapping[str, Any], filename: str | None) -> str | None:
extension = cls._string_value(value.get("extension"))
if extension:
return extension if extension.startswith(".") else f".{extension}"
if filename and "." in filename:
return f".{filename.rsplit('.', 1)[1]}"
return None
@staticmethod
def _file_type_from_payload(value: Mapping[str, Any], mime_type: str | None) -> FileType:
explicit_type = value.get("type") or value.get("file_type")
if isinstance(explicit_type, str):
try:
return FileType(explicit_type)
except ValueError:
pass
if mime_type:
if mime_type.startswith("image/"):
return FileType.IMAGE
if mime_type.startswith("audio/"):
return FileType.AUDIO
if mime_type.startswith("video/"):
return FileType.VIDEO
return FileType.DOCUMENT
return FileType.CUSTOM
@staticmethod
def _usage_from_metadata(metadata: Mapping[str, Any]) -> LLMUsage | None:
agent_backend = metadata.get("agent_backend")
if not isinstance(agent_backend, Mapping):
return None
usage = agent_backend.get("usage")
if not isinstance(usage, Mapping):
return None
try:
return LLMUsage.from_metadata(usage)
except (TypeError, ValueError):
return None
@staticmethod
def _build_node_metadata(
*,
metadata: dict[str, Any],
usage: LLMUsage | None,
) -> dict[WorkflowNodeExecutionMetadataKey, Any]:
node_metadata: dict[WorkflowNodeExecutionMetadataKey, Any] = {
WorkflowNodeExecutionMetadataKey.AGENT_LOG: metadata,
}
if usage is not None:
node_metadata[WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS] = usage.total_tokens
node_metadata[WorkflowNodeExecutionMetadataKey.TOTAL_PRICE] = usage.total_price
node_metadata[WorkflowNodeExecutionMetadataKey.CURRENCY] = usage.currency
return node_metadata
@staticmethod
def _with_terminal_metadata(
metadata: dict[str, Any],
event: AgentBackendInternalEvent,
terminal_status: str,
) -> dict[str, Any]:
updated = dict(metadata)
agent_backend = dict(updated.get("agent_backend") or {})
agent_backend.update(
{
"run_id": event.run_id,
"terminal_event_id": event.source_event_id,
"status": terminal_status,
}
)
session_snapshot = None
if isinstance(event, AgentBackendRunSucceededInternalEvent | AgentBackendRunPausedInternalEvent):
session_snapshot = event.session_snapshot
if session_snapshot is not None:
agent_backend["session_snapshot"] = {
"layer_count": len(session_snapshot.layers),
}
updated["agent_backend"] = agent_backend
updated["terminal_event_type"] = AgentBackendInternalEventType(event.type).value
return updated
@@ -0,0 +1,55 @@
from __future__ import annotations
from typing import Any
from models.agent_config_entities import AgentSoulConfig
SUPPORTED_AGENT_BACKEND_FEATURES = frozenset(
{
"system_prompt",
"workflow_prompt",
"workflow_context",
"model",
"structured_output",
}
)
RESERVED_AGENT_BACKEND_FEATURES = frozenset(
{
"skills_files",
"tools",
"knowledge",
"human",
"env",
"sandbox",
"memory",
}
)
def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any]:
"""Describe PRD capabilities that are persisted but not executed in phase 3."""
warnings: list[dict[str, str]] = []
soul_dump = agent_soul.model_dump(mode="json")
for section in sorted(RESERVED_AGENT_BACKEND_FEATURES):
value = soul_dump.get(section)
has_value = bool(value)
if isinstance(value, dict):
has_value = any(bool(item) for item in value.values())
if has_value:
warnings.append(
{
"section": f"agent_soul.{section}",
"code": "agent_backend_layer_not_available",
"message": f"{section} is saved in Agent Soul but is not executed by Agent backend in phase 3.",
}
)
reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed")
return {
"supported": sorted(SUPPORTED_AGENT_BACKEND_FEATURES),
"reserved": sorted(RESERVED_AGENT_BACKEND_FEATURES),
"reserved_status": reserved_status,
"unsupported_runtime_warnings": warnings,
}
@@ -0,0 +1,288 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Literal, Protocol, cast
from dify_agent.protocol import CreateRunRequest, ExecutionContext
from clients.agent_backend import (
AgentBackendModelConfig,
AgentBackendOutputConfig,
AgentBackendRunRequestBuilder,
AgentBackendWorkflowNodeRunInput,
redact_for_agent_backend_log,
)
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.variables.segments import Segment
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
from models.agent_config_entities import (
AgentSoulConfig,
DeclaredOutputConfig,
DeclaredOutputType,
WorkflowNodeJobConfig,
)
from .runtime_feature_manifest import build_runtime_feature_manifest
class WorkflowAgentRuntimeRequestBuildError(ValueError):
"""Raised when workflow state cannot be mapped to a valid Agent backend run request."""
def __init__(self, error_code: str, message: str) -> None:
self.error_code = error_code
super().__init__(message)
class VariablePoolReader(Protocol):
def get(self, selector: Sequence[str], /) -> Segment | None: ...
def get_by_prefix(self, prefix: str, /) -> Mapping[str, object]: ...
class CredentialsProvider(Protocol):
def fetch(self, provider_name: str, model_name: str) -> dict[str, Any]: ...
@dataclass(frozen=True, slots=True)
class WorkflowAgentRuntimeBuildContext:
dify_context: DifyRunContext
workflow_id: str
workflow_run_id: str | None
node_id: str
node_execution_id: str
variable_pool: VariablePoolReader
binding: WorkflowAgentNodeBinding
agent: Agent
snapshot: AgentConfigSnapshot
@dataclass(frozen=True, slots=True)
class WorkflowAgentRuntimeRequest:
request: CreateRunRequest
redacted_request: dict[str, Any]
agent_soul: AgentSoulConfig
node_job: WorkflowNodeJobConfig
metadata: dict[str, Any]
class WorkflowAgentRuntimeRequestBuilder:
"""Build public Dify Agent run requests from workflow Agent v2 runtime state."""
def __init__(
self,
*,
credentials_provider: CredentialsProvider,
request_builder: AgentBackendRunRequestBuilder | None = None,
) -> None:
self._credentials_provider = credentials_provider
self._request_builder = request_builder or AgentBackendRunRequestBuilder()
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)
if agent_soul.model is None:
raise WorkflowAgentRuntimeRequestBuildError(
"agent_model_not_configured",
"Workflow Agent node requires Agent Soul model config.",
)
metadata = self._build_metadata(context, agent_soul, node_job)
workflow_context_prompt = self._build_workflow_context_prompt(context, node_job)
workflow_job_prompt = node_job.workflow_prompt.strip() or "Run this workflow Agent Node for the current run."
user_prompt = workflow_context_prompt.strip() or "Use the current workflow context."
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
request = self._request_builder.build_for_workflow_node(
AgentBackendWorkflowNodeRunInput(
model=AgentBackendModelConfig(
tenant_id=context.dify_context.tenant_id,
plugin_id=agent_soul.model.plugin_id,
model_provider=agent_soul.model.model_provider,
model=agent_soul.model.model,
user_id=context.dify_context.user_id,
credentials=self._normalize_credentials(credentials),
model_settings=cast(dict[str, Any], agent_soul.model.model_settings),
),
execution_context=ExecutionContext(
tenant_id=context.dify_context.tenant_id,
app_id=context.dify_context.app_id,
workflow_id=context.workflow_id,
workflow_run_id=context.workflow_run_id,
node_id=context.node_id,
node_execution_id=context.node_execution_id,
conversation_id=get_system_text(context.variable_pool, SystemVariableKey.CONVERSATION_ID),
agent_id=context.agent.id,
agent_config_version_id=context.snapshot.id,
invoke_from=self._agent_backend_invoke_from(context.dify_context.invoke_from),
),
agent_soul_prompt=agent_soul.prompt.system_prompt or None,
workflow_node_job_prompt=workflow_job_prompt,
user_prompt=user_prompt,
output=self._build_output_config(node_job.declared_outputs),
idempotency_key=self._idempotency_key(context),
metadata=metadata,
)
)
redacted = cast(dict[str, Any], redact_for_agent_backend_log(request))
return WorkflowAgentRuntimeRequest(
request=request,
redacted_request=redacted,
agent_soul=agent_soul,
node_job=node_job,
metadata=metadata,
)
@staticmethod
def _agent_backend_invoke_from(invoke_from: InvokeFrom) -> Literal["workflow_run", "single_step"]:
if invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.VALIDATION}:
return "single_step"
return "workflow_run"
@staticmethod
def _idempotency_key(context: WorkflowAgentRuntimeBuildContext) -> str:
if context.workflow_run_id:
return f"{context.workflow_run_id}:{context.node_execution_id}"
return context.node_execution_id
@staticmethod
def _build_metadata(
context: WorkflowAgentRuntimeBuildContext,
agent_soul: AgentSoulConfig,
node_job: WorkflowNodeJobConfig,
) -> dict[str, Any]:
return {
"tenant_id": context.dify_context.tenant_id,
"app_id": context.dify_context.app_id,
"workflow_id": context.workflow_id,
"workflow_run_id": context.workflow_run_id,
"node_id": context.node_id,
"node_execution_id": context.node_execution_id,
"agent_id": context.agent.id,
"agent_config_snapshot_id": context.snapshot.id,
"binding_id": context.binding.id,
"workflow_node_job_mode": node_job.mode.value,
"runtime_support": build_runtime_feature_manifest(agent_soul),
}
def _build_workflow_context_prompt(
self,
context: WorkflowAgentRuntimeBuildContext,
node_job: WorkflowNodeJobConfig,
) -> str:
lines = ["Workflow context loaded for this run:"]
query = get_system_text(context.variable_pool, SystemVariableKey.QUERY)
if query:
lines.append(f"- User query: {query}")
resolved_outputs = self._resolve_previous_node_outputs(
context.variable_pool,
node_job.previous_node_output_refs,
)
if resolved_outputs:
lines.append("- Previous node outputs:")
for item in resolved_outputs:
lines.append(f" - {item['label']}: {item['value']}")
lines.append("The above workflow context is run-specific. Do not treat it as Agent Soul or persistent memory.")
return "\n".join(lines)
def _resolve_previous_node_outputs(
self,
variable_pool: VariablePoolReader,
refs: Sequence[Mapping[str, Any]],
) -> list[dict[str, Any]]:
resolved: list[dict[str, Any]] = []
for ref in refs:
selector = self._selector_from_ref(ref)
if not selector:
raise WorkflowAgentRuntimeRequestBuildError(
"invalid_previous_node_output_ref",
"Workflow Agent node has invalid previous node output ref.",
)
segment = variable_pool.get(selector)
if segment is None:
raise WorkflowAgentRuntimeRequestBuildError(
"missing_previous_node_output",
f"Workflow Agent node cannot resolve previous node output {'.'.join(selector)}.",
)
value = getattr(segment, "value", None)
resolved.append(
{
"label": ".".join(selector),
"value": self._summarize_value(value),
}
)
return resolved
@staticmethod
def _selector_from_ref(ref: Mapping[str, Any]) -> list[str] | None:
for key in ("selector", "variable_selector", "value_selector"):
value = ref.get(key)
if isinstance(value, list) and all(isinstance(item, str) for item in value):
return value
node_id = ref.get("node_id")
output_name = ref.get("output") or ref.get("name") or ref.get("variable") or ref.get("key")
if isinstance(node_id, str) and isinstance(output_name, str):
return [node_id, output_name]
return None
@staticmethod
def _summarize_value(value: Any) -> str:
text = str(value)
if len(text) > 2000:
return text[:2000] + "...[truncated]"
return text
@staticmethod
def _build_output_config(declared_outputs: Sequence[DeclaredOutputConfig]) -> AgentBackendOutputConfig | None:
if not declared_outputs:
return None
properties: dict[str, Any] = {}
required: list[str] = []
for output in declared_outputs:
properties[output.name] = WorkflowAgentRuntimeRequestBuilder._schema_for_declared_output(output)
if output.required:
required.append(output.name)
schema: dict[str, Any] = {"type": "object", "properties": properties}
if required:
schema["required"] = required
return AgentBackendOutputConfig(json_schema=schema)
@staticmethod
def _schema_for_declared_output(output: DeclaredOutputConfig) -> dict[str, Any]:
match output.type:
case DeclaredOutputType.STRING:
schema: dict[str, Any] = {"type": "string"}
case DeclaredOutputType.NUMBER:
schema = {"type": "number"}
case DeclaredOutputType.BOOLEAN:
schema = {"type": "boolean"}
case DeclaredOutputType.OBJECT:
schema = {"type": "object"}
case DeclaredOutputType.ARRAY:
schema = {"type": "array"}
case DeclaredOutputType.FILE:
schema = {
"type": "object",
"properties": {
"file_id": {"type": "string"},
"filename": {"type": "string"},
"mime_type": {"type": "string"},
"url": {"type": "string"},
},
}
if output.description:
schema["description"] = output.description
return schema
@staticmethod
def _normalize_credentials(credentials: Mapping[str, Any]) -> dict[str, str | int | float | bool | None]:
normalized: dict[str, str | int | float | bool | None] = {}
for key, value in credentials.items():
if isinstance(value, str | int | float | bool) or value is None:
normalized[key] = value
else:
normalized[key] = str(value)
return normalized
@@ -0,0 +1,388 @@
from __future__ import annotations
from collections import defaultdict, deque
from collections.abc import Iterator, Mapping, Sequence
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from graphon.enums import BuiltinNodeTypes
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
from models.model import UploadFile
from models.workflow import Workflow
from .entities import DifyAgentNodeData
class WorkflowAgentNodeValidationError(ValueError):
"""Raised when a Workflow Agent v2 node cannot be executed or published."""
class WorkflowAgentNodeValidator:
"""Validate Agent v2 workflow nodes against graph topology and persisted bindings."""
_LOCKED_AGENT_SOUL_KEYS = frozenset(
{
"agent_soul",
"soul",
"prompt",
"system_prompt",
"skills_files",
"skills",
"files",
"tools",
"dify_tools",
"cli_tools",
"knowledge",
"env",
"environment",
"sandbox",
"sandbox_provider",
"memory",
"memory_strategy",
"model",
"app_features",
"app_variables",
"misc_legacy",
}
)
_SUPPORTED_HUMAN_CONTACT_CHANNELS = frozenset({"email", "slack", "web_app", "webapp", "chat"})
@classmethod
def validate_draft_workflow(cls, *, session: Session, workflow: Workflow) -> None:
cls._validate_workflow(session=session, workflow=workflow, require_binding=False)
@classmethod
def validate_published_workflow(cls, *, session: Session, workflow: Workflow) -> None:
cls._validate_workflow(session=session, workflow=workflow, require_binding=True)
@classmethod
def _validate_workflow(cls, *, session: Session, workflow: Workflow, require_binding: bool) -> None:
graph = workflow.graph_dict
topology = _WorkflowGraphTopology.from_graph(graph)
for node_id, node_data in cls.iter_agent_v2_nodes(graph):
cls._validate_node_schema(node_id=node_id, node_data=node_data)
binding = cls._find_binding(
session=session,
tenant_id=workflow.tenant_id,
app_id=workflow.app_id,
workflow_id=workflow.id,
node_id=node_id,
)
if binding is None:
if require_binding:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {node_id} requires a binding before publishing."
)
continue
cls.validate_binding(session=session, binding=binding, topology=topology)
@classmethod
def validate_binding(
cls,
*,
session: Session,
binding: WorkflowAgentNodeBinding,
topology: _WorkflowGraphTopology | None = None,
) -> None:
if binding.agent_id is None:
raise WorkflowAgentNodeValidationError(f"Workflow Agent node {binding.node_id} is missing agent binding.")
if binding.current_snapshot_id is None:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} is missing config snapshot binding."
)
agent = session.scalar(
select(Agent)
.where(
Agent.tenant_id == binding.tenant_id,
Agent.id == binding.agent_id,
)
.limit(1)
)
if agent is None or agent.status == AgentStatus.ARCHIVED:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} references an unavailable agent."
)
snapshot = session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == binding.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == binding.current_snapshot_id,
)
.limit(1)
)
if snapshot is None:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} references a missing config snapshot."
)
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
if agent_soul.model is None:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} requires Agent Soul model config."
)
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
cls.validate_node_job(session=session, binding=binding, node_job=node_job, topology=topology)
@classmethod
def validate_node_job(
cls,
*,
session: Session,
binding: WorkflowAgentNodeBinding,
node_job: WorkflowNodeJobConfig,
topology: _WorkflowGraphTopology | None = None,
) -> None:
cls._validate_locked_agent_soul_not_overridden(binding=binding, node_job=node_job)
output_names: set[str] = set()
for output in node_job.declared_outputs:
if output.name in output_names:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} has duplicate output name {output.name}."
)
output_names.add(output.name)
for check in output.checks:
if check.benchmark_file_ref is not None:
cls._validate_file_ref(
session=session,
binding=binding,
file_ref=check.benchmark_file_ref,
ref_context=f"output {output.name} benchmark file",
)
for ref in node_job.previous_node_output_refs:
selector = cls.selector_from_ref(ref)
if selector is None:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} has invalid previous node output ref."
)
if topology is None:
continue
if len(selector) < 2:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} has incomplete previous node output ref."
)
source_node_id = selector[0]
if not topology.has_node(source_node_id):
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} references missing previous node {source_node_id}."
)
if not topology.is_upstream(source_node_id=source_node_id, target_node_id=binding.node_id):
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} references non-upstream previous node {source_node_id}."
)
for human_ref in node_job.human_contacts:
cls._validate_human_ref(binding=binding, human_ref=human_ref)
file_refs = node_job.metadata.get("file_refs")
if isinstance(file_refs, list):
for file_ref in file_refs:
if isinstance(file_ref, Mapping):
cls._validate_file_ref(
session=session,
binding=binding,
file_ref=file_ref,
ref_context="metadata file ref",
)
@staticmethod
def iter_agent_v2_nodes(graph_dict: Mapping[str, Any]) -> Iterator[tuple[str, Mapping[str, Any]]]:
nodes = graph_dict.get("nodes")
if not isinstance(nodes, list):
return
for node in nodes:
if not isinstance(node, Mapping):
continue
node_id = node.get("id")
node_data = node.get("data")
if not isinstance(node_id, str) or not isinstance(node_data, Mapping):
continue
if node_data.get("type") == BuiltinNodeTypes.AGENT and str(node_data.get("version")) == "2":
yield node_id, node_data
@staticmethod
def selector_from_ref(ref: Mapping[str, Any]) -> list[str] | None:
for key in ("selector", "variable_selector", "value_selector"):
value = ref.get(key)
if isinstance(value, list) and all(isinstance(item, str) for item in value):
return value
node_id = ref.get("node_id")
output_name = ref.get("output") or ref.get("name") or ref.get("variable") or ref.get("key")
if isinstance(node_id, str) and isinstance(output_name, str):
return [node_id, output_name]
return None
@staticmethod
def _validate_node_schema(*, node_id: str, node_data: Mapping[str, Any]) -> None:
try:
DifyAgentNodeData.model_validate(node_data)
except ValueError as exc:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {node_id} has invalid Agent v2 node schema: {exc}"
) from exc
@classmethod
def _validate_locked_agent_soul_not_overridden(
cls,
*,
binding: WorkflowAgentNodeBinding,
node_job: WorkflowNodeJobConfig,
) -> None:
forbidden_paths = cls._find_locked_agent_soul_paths(node_job.metadata)
if forbidden_paths:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} cannot override locked Agent Soul fields: "
f"{', '.join(sorted(forbidden_paths))}."
)
@classmethod
def _find_locked_agent_soul_paths(cls, value: Any, *, path: str = "metadata") -> set[str]:
if not isinstance(value, Mapping):
return set()
forbidden: set[str] = set()
for key, item in value.items():
key_text = str(key)
if key_text in cls._LOCKED_AGENT_SOUL_KEYS:
forbidden.add(f"{path}.{key_text}")
forbidden.update(cls._find_locked_agent_soul_paths(item, path=f"{path}.{key_text}"))
return forbidden
@classmethod
def _validate_human_ref(
cls,
*,
binding: WorkflowAgentNodeBinding,
human_ref: Mapping[str, Any],
) -> None:
contact_id = human_ref.get("contact_id") or human_ref.get("human_id") or human_ref.get("id")
if not isinstance(contact_id, str) or not contact_id:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} has invalid human contact ref."
)
tenant_id = human_ref.get("tenant_id")
if tenant_id is not None and tenant_id != binding.tenant_id:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} references out-of-scope human contact {contact_id}."
)
channel = human_ref.get("channel") or human_ref.get("method") or human_ref.get("contact_method")
if channel is not None and channel not in cls._SUPPORTED_HUMAN_CONTACT_CHANNELS:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} references unsupported human contact channel {channel}."
)
@staticmethod
def _validate_file_ref(
*,
session: Session,
binding: WorkflowAgentNodeBinding,
file_ref: Mapping[str, Any],
ref_context: str,
) -> None:
tenant_id = file_ref.get("tenant_id")
if tenant_id is not None and tenant_id != binding.tenant_id:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} references out-of-scope {ref_context}."
)
upload_file_id = (
file_ref.get("upload_file_id") or file_ref.get("file_id") or file_ref.get("id") or file_ref.get("reference")
)
if upload_file_id is None and (file_ref.get("url") or file_ref.get("remote_url")):
return
if not isinstance(upload_file_id, str) or not upload_file_id:
raise WorkflowAgentNodeValidationError(f"Workflow Agent node {binding.node_id} has invalid {ref_context}.")
upload_file = session.scalar(
select(UploadFile)
.where(
UploadFile.tenant_id == binding.tenant_id,
UploadFile.id == upload_file_id,
)
.limit(1)
)
if upload_file is None:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} references missing or out-of-scope {ref_context}."
)
@staticmethod
def _find_binding(
*,
session: Session,
tenant_id: str,
app_id: str,
workflow_id: str,
node_id: str,
) -> WorkflowAgentNodeBinding | None:
return session.scalar(
select(WorkflowAgentNodeBinding)
.where(
WorkflowAgentNodeBinding.tenant_id == tenant_id,
WorkflowAgentNodeBinding.app_id == app_id,
WorkflowAgentNodeBinding.workflow_id == workflow_id,
WorkflowAgentNodeBinding.node_id == node_id,
)
.limit(1)
)
class _WorkflowGraphTopology:
def __init__(self, *, node_ids: set[str], incoming: Mapping[str, Sequence[str]]) -> None:
self._node_ids = node_ids
self._incoming = incoming
@classmethod
def from_graph(cls, graph: Mapping[str, Any]) -> _WorkflowGraphTopology:
node_ids = cls._node_ids_from_graph(graph)
incoming: dict[str, list[str]] = defaultdict(list)
edges = graph.get("edges")
if isinstance(edges, list):
for edge in edges:
if not isinstance(edge, Mapping):
continue
source = edge.get("source")
target = edge.get("target")
if isinstance(source, str) and isinstance(target, str):
incoming[target].append(source)
return cls(node_ids=node_ids, incoming=incoming)
def has_node(self, node_id: str) -> bool:
return node_id in self._node_ids
def is_upstream(self, *, source_node_id: str, target_node_id: str) -> bool:
if source_node_id == target_node_id:
return False
visited: set[str] = set()
queue: deque[str] = deque(self._incoming.get(target_node_id, ()))
while queue:
candidate = queue.popleft()
if candidate == source_node_id:
return True
if candidate in visited:
continue
visited.add(candidate)
queue.extend(self._incoming.get(candidate, ()))
return False
@staticmethod
def _node_ids_from_graph(graph: Mapping[str, Any]) -> set[str]:
node_ids: set[str] = set()
nodes = graph.get("nodes")
if not isinstance(nodes, list):
return node_ids
for node in nodes:
if not isinstance(node, Mapping):
continue
node_id = node.get("id")
if isinstance(node_id, str):
node_ids.add(node_id)
return node_ids