mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
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:
co-authored by
autofix-ci[bot]
parent
56d4d54c16
commit
60cd346fa6
@@ -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
|
||||
Reference in New Issue
Block a user