mirror of
https://github.com/langgenius/dify.git
synced 2026-11-12 06:11:12 +08:00
1211 lines
54 KiB
Python
1211 lines
54 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from collections.abc import Generator, Mapping, Sequence
|
|
from contextlib import contextmanager, suppress
|
|
from time import perf_counter
|
|
from typing import TYPE_CHECKING, Any, Literal, override
|
|
|
|
from dify_agent.protocol import CancelRunRequest
|
|
from dify_agent.protocol.snapshot import SessionSnapshot
|
|
|
|
from clients.agent_backend import (
|
|
AgentBackendAgentMessageDeltaInternalEvent,
|
|
AgentBackendError,
|
|
AgentBackendHTTPError,
|
|
AgentBackendInternalEventType,
|
|
AgentBackendRunCancelledInternalEvent,
|
|
AgentBackendRunClient,
|
|
AgentBackendRunEventAdapter,
|
|
AgentBackendRunFailedInternalEvent,
|
|
AgentBackendRunSucceededInternalEvent,
|
|
AgentBackendRunWaitingInternalEvent,
|
|
AgentBackendStreamError,
|
|
AgentBackendStreamInternalEvent,
|
|
AgentBackendTransportError,
|
|
AgentBackendValidationError,
|
|
)
|
|
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
|
|
from core.workflow.node_execution_process_data import WORKFLOW_TOOL_INVOCATION_ID_KEY
|
|
from core.workflow.nodes.agent_v2.entities import DifyAgentNodeData
|
|
from core.workflow.nodes.agent_v2.output_adapter import WorkflowAgentOutputAdapter
|
|
from core.workflow.nodes.agent_v2.output_failure_orchestrator import (
|
|
FailedOutput,
|
|
OutputFailureDecision,
|
|
OutputFailureKind,
|
|
OutputFailureOrchestrator,
|
|
)
|
|
from core.workflow.nodes.agent_v2.output_type_checker import OutputTypeCheckOutcome, PerOutputTypeChecker
|
|
from core.workflow.nodes.agent_v2.runtime_request_builder import (
|
|
WorkflowAgentRuntimeBuildContext,
|
|
WorkflowAgentRuntimeRequestBuilder,
|
|
WorkflowAgentRuntimeRequestBuildError,
|
|
)
|
|
from core.workflow.nodes.agent_v2.session_store import WorkflowAgentSessionScope, WorkflowAgentWorkspaceStore
|
|
from core.workflow.system_variables import SystemVariableKey, get_system_text
|
|
from graphon.engine_events import NodeRunPauseRequestedEvent
|
|
from graphon.entities.pause_reason import SchedulingPause
|
|
from graphon.enums import (
|
|
BuiltinNodeTypes,
|
|
ErrorStrategy,
|
|
NodeExecutionType,
|
|
WorkflowNodeExecutionMetadataKey,
|
|
WorkflowNodeExecutionStatus,
|
|
)
|
|
from graphon.node_events import (
|
|
NodeEventPayload,
|
|
NodeRunResult,
|
|
RunRetrieverResourceEvent,
|
|
StreamChunkEvent,
|
|
StreamCompletedEvent,
|
|
)
|
|
from graphon.nodes.base.node import Node
|
|
from graphon.nodes.base.variable_template_parser import VariableTemplateParser
|
|
from models.agent_config_entities import WorkflowNodeJobConfig, WorkflowOutputRoutes
|
|
from services.agent.knowledge_citations import knowledge_sources_from_stream_event
|
|
from services.agent.prompt_mentions import extract_workflow_node_output_selectors
|
|
from services.agent.workspace_service import AgentWorkspaceNotFoundError
|
|
from services.workflow_runtime.agent_binding import WorkflowAgentBindingError, WorkflowAgentBindingResolver
|
|
|
|
if TYPE_CHECKING:
|
|
from graphon.runtime import InitParams, RuntimeState
|
|
from runtime_contracts.builder.native.agent_context import BuilderAgentExecutionContext
|
|
from services.agent.workflow_run_recovery import WorkflowAgentRunRecovery
|
|
from services.dify_builder.native_testing.agent_preparation import AgentPreparationService
|
|
from services.dify_builder.native_testing.agent_recovery import ConfirmedSnapshotIdentity
|
|
from services.dify_builder.native_testing.confirmed_agent_commands import ConfirmedAgentCommands
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@contextmanager
|
|
def _confirmed_node_phase(
|
|
context: BuilderAgentExecutionContext,
|
|
phase: Literal["parent_restore", "recover_existing", "load_wait", "continue_wait"],
|
|
) -> Generator[None]:
|
|
"""Disjoint node recovery phases; their native reads and transport spans are nested."""
|
|
started, error_type = perf_counter(), "none"
|
|
try:
|
|
yield
|
|
except BaseException as error:
|
|
error_type = type(error).__name__
|
|
raise
|
|
finally:
|
|
with suppress(Exception): # Observability cannot replace a native control exception.
|
|
logger.info(
|
|
"Confirmed node timing request_id=%s run_id=%s phase=%s duration_seconds=%.6f error_type=%s",
|
|
context.ref.request_id,
|
|
context.native_run_id,
|
|
phase,
|
|
perf_counter() - started,
|
|
error_type,
|
|
)
|
|
|
|
|
|
# Terminal and waiting controls that `_consume_event_stream` may return.
|
|
# Stream + started events are filtered out (plain-run text deltas are yielded
|
|
# as stream chunks instead); transport errors
|
|
# are surfaced as a separate StreamCompletedEvent in the second tuple slot.
|
|
type _TerminalAgentBackendEvent = (
|
|
AgentBackendRunSucceededInternalEvent
|
|
| AgentBackendRunFailedInternalEvent
|
|
| AgentBackendRunCancelledInternalEvent
|
|
| AgentBackendRunWaitingInternalEvent
|
|
)
|
|
|
|
|
|
class DifyAgentNode(Node[DifyAgentNodeData]):
|
|
"""Execute a frozen node job and select one workflow exit on success.
|
|
|
|
Enabled routing selects a stable route ID through the required ``switch``
|
|
output. Disabled routing remains executable so
|
|
graph-owned default values can continue without a selected route; Graphon
|
|
still promotes nodes that opt into its failure branch.
|
|
"""
|
|
|
|
node_type = BuiltinNodeTypes.AGENT
|
|
|
|
def __init__(
|
|
self,
|
|
node_id: str,
|
|
data: DifyAgentNodeData,
|
|
*,
|
|
init_params: InitParams,
|
|
runtime_state: RuntimeState,
|
|
binding_resolver: WorkflowAgentBindingResolver,
|
|
runtime_request_builder: WorkflowAgentRuntimeRequestBuilder,
|
|
agent_backend_client: AgentBackendRunClient,
|
|
event_adapter: AgentBackendRunEventAdapter,
|
|
output_adapter: WorkflowAgentOutputAdapter,
|
|
type_checker: PerOutputTypeChecker,
|
|
failure_orchestrator: OutputFailureOrchestrator,
|
|
session_store: WorkflowAgentWorkspaceStore,
|
|
agent_preparation: AgentPreparationService | None = None,
|
|
agent_commands: ConfirmedAgentCommands | None = None,
|
|
agent_run_recovery: WorkflowAgentRunRecovery | None = None,
|
|
) -> None:
|
|
super().__init__(
|
|
node_id=node_id,
|
|
data=data,
|
|
init_params=init_params,
|
|
runtime_state=runtime_state,
|
|
)
|
|
if self.node_data.agent_output_routes.enabled and self.error_strategy == ErrorStrategy.DEFAULT_VALUE:
|
|
raise ValueError("Output routes do not support the node-level default-value error strategy.")
|
|
if self.node_data.agent_output_routes.enabled:
|
|
self.execution_type = NodeExecutionType.BRANCH
|
|
self._binding_resolver = binding_resolver
|
|
self._runtime_request_builder = runtime_request_builder
|
|
self._agent_backend_client = agent_backend_client
|
|
self._event_adapter = event_adapter
|
|
self._output_adapter = output_adapter
|
|
self._type_checker = type_checker
|
|
self._failure_orchestrator = failure_orchestrator
|
|
self._session_store = session_store
|
|
self._agent_preparation = agent_preparation
|
|
self._agent_commands = agent_commands
|
|
self._agent_run_recovery = agent_run_recovery
|
|
|
|
@override
|
|
def _get_error_strategy(self) -> ErrorStrategy | None:
|
|
context = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
|
|
# Confirmed control failures cannot become default output or an alternate branch.
|
|
if context.builder_agent_execution is not None:
|
|
return None
|
|
return super()._get_error_strategy()
|
|
|
|
@classmethod
|
|
@override
|
|
def version(cls) -> str:
|
|
return "2"
|
|
|
|
@override
|
|
def populate_start_event(self, event) -> None:
|
|
event.extras["agent_node"] = {"version": "2", "agent_node_kind": self.node_data.agent_node_kind}
|
|
|
|
@override
|
|
def _run(self) -> Generator[NodeEventPayload | NodeRunPauseRequestedEvent]:
|
|
inputs: dict[str, Any] = {}
|
|
process_data: dict[str, Any] = {}
|
|
metadata: dict[str, Any] = {
|
|
"agent_backend": {
|
|
"status": "not_started",
|
|
}
|
|
}
|
|
try:
|
|
yield from self._run_inner(inputs=inputs, process_data=process_data, metadata=metadata)
|
|
except Exception as error:
|
|
if not process_data:
|
|
raise
|
|
yield self._failure_event(
|
|
inputs=inputs,
|
|
process_data=process_data,
|
|
metadata=metadata,
|
|
error=str(error),
|
|
error_type="agent_workflow_node_runtime_error",
|
|
)
|
|
|
|
def _run_inner(
|
|
self,
|
|
*,
|
|
inputs: dict[str, Any],
|
|
process_data: dict[str, Any],
|
|
metadata: dict[str, Any],
|
|
) -> Generator[NodeEventPayload | NodeRunPauseRequestedEvent]:
|
|
from runtime_contracts.builder.execution import BuilderExecutionPolicyError
|
|
from services.dify_builder.native_testing.agent_binding import AgentSnapshotChanged
|
|
|
|
dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
|
|
confirmed = dify_ctx.builder_agent_execution
|
|
workflow_id = self.init_params.workflow_id
|
|
workflow_run_id = get_system_text(
|
|
self.runtime_state.variable_pool,
|
|
SystemVariableKey.WORKFLOW_EXECUTION_ID,
|
|
)
|
|
# Chatflow sessions also carry their owning conversation.
|
|
conversation_id = get_system_text(
|
|
self.runtime_state.variable_pool,
|
|
SystemVariableKey.CONVERSATION_ID,
|
|
)
|
|
if confirmed is not None and (
|
|
workflow_run_id != confirmed.native_run_id
|
|
or conversation_id is not None
|
|
or dify_ctx.invoke_from.value != "debugger"
|
|
or dify_ctx.user_from.value != "account"
|
|
or dify_ctx.builder_execution is not None
|
|
or dify_ctx.native_test_required
|
|
or dify_ctx.native_test_ref is not None
|
|
or self._agent_preparation is None
|
|
or self._agent_commands is None
|
|
or self._agent_run_recovery is None
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
|
|
# ──── Setup: resolve binding once + extract declared outputs for stage 4 checks ────
|
|
try:
|
|
builder_resolution: dict[str, Any] = {}
|
|
# The ordinary Builder Test Run always supplies this server-owned
|
|
# map, including {} for Agent-only graphs. Ordinary runs leave it
|
|
# None. Recheck its graph selection at the actual Agent boundary.
|
|
if dify_ctx.builder_tool_credential_bindings is not None:
|
|
from core.dify_builder.models import Actor
|
|
from services.dify_builder.native_testing.agent_binding import authorize_graph_agent_selection
|
|
|
|
nodes = [
|
|
node
|
|
for node in self.init_params.graph_config.get("nodes", [])
|
|
if isinstance(node, Mapping) and node.get("id") == self._node_id
|
|
]
|
|
if len(nodes) != 1 or not isinstance(nodes[0].get("data"), Mapping):
|
|
raise BuilderExecutionPolicyError("agent_binding_not_prepared")
|
|
actor = Actor(tenant_id=dify_ctx.tenant_id, account_id=dify_ctx.user_id)
|
|
selection = authorize_graph_agent_selection(
|
|
actor=actor, app_id=dify_ctx.app_id, node_data=nodes[0]["data"]
|
|
)
|
|
builder_resolution = {
|
|
"builder_actor": actor,
|
|
"builder_authorization": selection.authorization,
|
|
"selected_agent_id": selection.agent_id,
|
|
"selected_snapshot_id": selection.snapshot_id,
|
|
}
|
|
existing_scope = self._session_store.load_existing_node_execution_scope(
|
|
tenant_id=dify_ctx.tenant_id,
|
|
app_id=dify_ctx.app_id,
|
|
workflow_id=workflow_id,
|
|
workflow_run_id=workflow_run_id,
|
|
node_id=self._node_id,
|
|
node_execution_id=self.execution_id,
|
|
conversation_id=conversation_id,
|
|
workflow_tool_invocation_id=dify_ctx.workflow_tool_invocation_id,
|
|
)
|
|
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,
|
|
binding_id=existing_scope.workflow_agent_binding_id if existing_scope is not None else None,
|
|
snapshot_id=existing_scope.agent_config_snapshot_id if existing_scope is not None else None,
|
|
conversation_id=conversation_id if dify_ctx.workflow_tool_invocation_id is None else None,
|
|
**builder_resolution,
|
|
)
|
|
except (AgentSnapshotChanged, BuilderExecutionPolicyError) as error:
|
|
yield self._failure_event(
|
|
inputs=inputs,
|
|
process_data=process_data,
|
|
metadata=metadata,
|
|
error=str(error),
|
|
error_type="agent_snapshot_changed" if isinstance(error, AgentSnapshotChanged) else error.reason_code,
|
|
)
|
|
return
|
|
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 AgentWorkspaceNotFoundError 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
|
|
|
|
process_data.update(
|
|
{
|
|
"agent_id": bundle.agent.id,
|
|
"agent_config_snapshot_id": bundle.snapshot.id,
|
|
"workflow_agent_binding_id": bundle.binding.id,
|
|
}
|
|
)
|
|
if dify_ctx.workflow_tool_invocation_id is not None:
|
|
process_data[WORKFLOW_TOOL_INVOCATION_ID_KEY] = dify_ctx.workflow_tool_invocation_id
|
|
session_scope = existing_scope or WorkflowAgentSessionScope(
|
|
tenant_id=dify_ctx.tenant_id,
|
|
app_id=dify_ctx.app_id,
|
|
workflow_id=workflow_id,
|
|
workflow_run_id=workflow_run_id,
|
|
node_id=self._node_id,
|
|
node_execution_id=self.execution_id,
|
|
workflow_agent_binding_id=bundle.binding.id,
|
|
agent_id=bundle.agent.id,
|
|
agent_config_snapshot_id=bundle.snapshot.id,
|
|
conversation_id=conversation_id,
|
|
workflow_tool_invocation_id=dify_ctx.workflow_tool_invocation_id,
|
|
)
|
|
|
|
node_job = WorkflowNodeJobConfig.model_validate(bundle.binding.node_job_config_dict)
|
|
node_job.output_routes.validate_for_execution()
|
|
custom_outputs = list(node_job.declared_outputs)
|
|
outputs_by_name = {output.name: output for output in custom_outputs}
|
|
|
|
stored_session = self._session_store.load_or_create_node_execution_session(
|
|
session_scope,
|
|
home_snapshot_id=bundle.snapshot.home_snapshot_id,
|
|
)
|
|
# ──── Retry loop (Stage 4 §7) ────
|
|
attempt = 0
|
|
while True:
|
|
parent_node = None
|
|
snapshot_identity = None
|
|
after = None
|
|
runtime_request = None
|
|
create_response = None
|
|
try:
|
|
if confirmed is not None:
|
|
from services.agent.workflow_run_recovery import AgentCreateAcknowledgementUnknown
|
|
|
|
try:
|
|
create_response, parent_node, after, snapshot_identity = self._start_or_resume_confirmed(
|
|
dify_context=dify_ctx,
|
|
session_scope=session_scope,
|
|
stored_session=stored_session,
|
|
process_data=process_data,
|
|
)
|
|
except AgentCreateAcknowledgementUnknown:
|
|
yield self._control_pause(inputs, process_data, metadata, "agent_create_ack_unknown")
|
|
return
|
|
except BuilderExecutionPolicyError as error:
|
|
if error.reason_code == "native_agent_continuation_unknown":
|
|
yield self._control_pause(inputs, process_data, metadata, error.reason_code)
|
|
return
|
|
raise
|
|
inputs["agent_backend_request"] = {"execution_mode": "builder_test", "profile": "confirmed"}
|
|
metadata["attempt"] = 0
|
|
metadata["agent_backend"] = {"run_id": create_response.run_id, "status": create_response.status}
|
|
else:
|
|
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.execution_id,
|
|
variable_pool=self.runtime_state.variable_pool,
|
|
binding=bundle.binding,
|
|
agent=bundle.agent,
|
|
snapshot=bundle.snapshot,
|
|
binding_id=stored_session.binding_id,
|
|
backend_binding_ref=stored_session.backend_binding_ref,
|
|
attempt=attempt,
|
|
session_snapshot=stored_session.session_snapshot,
|
|
)
|
|
)
|
|
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 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
|
|
|
|
# Capture inputs only from the first attempt so retry doesn't churn the
|
|
# node's "inputs" payload that ends up in the workflow detail view.
|
|
if confirmed is None:
|
|
assert runtime_request is not None
|
|
if attempt == 0:
|
|
inputs["agent_backend_request"] = runtime_request.redacted_request
|
|
metadata.clear()
|
|
metadata.update(runtime_request.metadata)
|
|
metadata["attempt"] = attempt
|
|
try:
|
|
create_response = self._agent_backend_client.create_run(runtime_request.request)
|
|
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
|
|
|
|
assert create_response is not None
|
|
metadata["agent_backend"] = {
|
|
**dict(metadata.get("agent_backend") or {}),
|
|
"run_id": create_response.run_id,
|
|
"status": create_response.status,
|
|
}
|
|
|
|
terminal_event, exhausted = yield from self._consume_event_stream(
|
|
create_response.run_id,
|
|
inputs=inputs,
|
|
process_data=process_data,
|
|
metadata=metadata,
|
|
stream_text=not custom_outputs
|
|
and not node_job.output_routes.enabled
|
|
and self.error_strategy != ErrorStrategy.DEFAULT_VALUE,
|
|
after=after,
|
|
)
|
|
if isinstance(terminal_event, AgentBackendRunWaitingInternalEvent):
|
|
if confirmed is None or parent_node is None:
|
|
yield self._failure_event(
|
|
inputs=inputs,
|
|
process_data=process_data,
|
|
metadata=metadata,
|
|
error="Agent wait has no confirmed owner",
|
|
error_type="agent_wait_unsupported",
|
|
)
|
|
return
|
|
try:
|
|
if terminal_event.run_id != create_response.run_id or terminal_event.source_event_id is None:
|
|
raise BuilderExecutionPolicyError("native_agent_checkpoint_unavailable")
|
|
assert self._agent_commands is not None
|
|
wait = self._agent_commands.prepare_wait(
|
|
ref=confirmed.ref,
|
|
parent_node=parent_node,
|
|
agent_generation=terminal_event.generation,
|
|
backend_wait_event_id=terminal_event.source_event_id,
|
|
)
|
|
process_data["agent_wait"] = wait.model_dump(mode="json")
|
|
except BuilderExecutionPolicyError:
|
|
yield self._control_pause(inputs, process_data, metadata, "native_agent_wait_unverified")
|
|
return
|
|
yield self._control_pause(inputs, process_data, metadata, "agent_tool_approval")
|
|
return
|
|
if (
|
|
confirmed is not None
|
|
and not self._is_graph_aborted()
|
|
and (exhausted is not None or terminal_event is None)
|
|
):
|
|
yield self._control_pause(inputs, process_data, metadata, "agent_stream_ack_unknown")
|
|
return
|
|
if metadata.get("knowledge_fs_sources"):
|
|
yield RunRetrieverResourceEvent(retriever_resources=metadata["knowledge_fs_sources"], context="")
|
|
# None means no post-exit snapshot was produced; leave the previously stored session snapshot untouched.
|
|
if (
|
|
isinstance(terminal_event, AgentBackendRunFailedInternalEvent | AgentBackendRunCancelledInternalEvent)
|
|
and terminal_event.session_snapshot is not None
|
|
):
|
|
self._save_session_snapshot(
|
|
session_scope=session_scope,
|
|
binding_id=stored_session.binding_id,
|
|
snapshot=terminal_event.session_snapshot,
|
|
metadata=metadata,
|
|
confirmed_identity=snapshot_identity,
|
|
)
|
|
if exhausted is not None:
|
|
# Streaming error / unexpected end — surface immediately without
|
|
# retrying because the failure is transport-level.
|
|
yield exhausted
|
|
return
|
|
if terminal_event is None:
|
|
yield StreamCompletedEvent(
|
|
node_run_result=self._output_adapter.build_stream_exhausted_result(
|
|
inputs=inputs,
|
|
process_data=process_data,
|
|
metadata=metadata,
|
|
)
|
|
)
|
|
return
|
|
|
|
# A failed attempt does not retire the product-owned Binding. The
|
|
# Workflow Run terminal lifecycle event owns that transition.
|
|
if not isinstance(terminal_event, AgentBackendRunSucceededInternalEvent):
|
|
yield StreamCompletedEvent(
|
|
node_run_result=self._output_adapter.build_failure_result(
|
|
event=terminal_event,
|
|
inputs=inputs,
|
|
process_data=process_data,
|
|
metadata=metadata,
|
|
)
|
|
)
|
|
return
|
|
|
|
self._save_session_snapshot(
|
|
session_scope=session_scope,
|
|
binding_id=stored_session.binding_id,
|
|
snapshot=terminal_event.session_snapshot,
|
|
metadata=metadata,
|
|
confirmed_identity=snapshot_identity,
|
|
)
|
|
|
|
success_event = terminal_event
|
|
if custom_outputs:
|
|
# ──── Stage 4: per-output type check ────
|
|
type_check = self._type_checker.check(
|
|
declared_outputs=custom_outputs,
|
|
raw_output=terminal_event.output,
|
|
tenant_id=dify_ctx.tenant_id,
|
|
)
|
|
self._record_type_check_metadata(metadata, type_check)
|
|
|
|
if type_check.has_failures:
|
|
# ──── Stage 4: orchestrate retry / default / fail ────
|
|
failures = [
|
|
FailedOutput(
|
|
declared=outputs_by_name[result.name],
|
|
failure_kind=OutputFailureKind.TYPE_CHECK,
|
|
reason=result.reason,
|
|
)
|
|
for result in type_check.failures
|
|
if result.name in outputs_by_name
|
|
]
|
|
outcome = self._failure_orchestrator.decide(failures=failures, current_attempt=attempt)
|
|
metadata["output_failure_decision"] = outcome.decision.value
|
|
metadata["output_failure_reason"] = outcome.primary_reason
|
|
|
|
if outcome.decision == OutputFailureDecision.RETRY:
|
|
if confirmed is not None:
|
|
yield self._failure_event(
|
|
inputs=inputs,
|
|
process_data=process_data,
|
|
metadata=metadata,
|
|
error="Confirmed Agent output validation cannot create another child automatically.",
|
|
error_type="native_agent_output_retry_unavailable",
|
|
)
|
|
return
|
|
attempt = outcome.next_attempt
|
|
continue
|
|
|
|
if outcome.decision == OutputFailureDecision.USE_DEFAULT:
|
|
success_event = self._patch_event_with_defaults(terminal_event, outcome.per_output_actions)
|
|
else:
|
|
error_type = (
|
|
"output_type_check_failed_fail_branch"
|
|
if outcome.decision == OutputFailureDecision.TAKE_FAIL_BRANCH
|
|
else "output_type_check_failed"
|
|
)
|
|
yield self._failure_event(
|
|
inputs=inputs,
|
|
process_data=process_data,
|
|
metadata=metadata,
|
|
error=outcome.primary_reason,
|
|
error_type=error_type,
|
|
)
|
|
return
|
|
|
|
# Select only after this attempt's custom outputs have passed. Failed
|
|
# route output uses the node's normal retry / failure-branch handling.
|
|
routes = node_job.output_routes
|
|
edge_source_handle = "source"
|
|
if routes.enabled:
|
|
selected = success_event.output.get("switch") if isinstance(success_event.output, dict) else None
|
|
if not isinstance(selected, str) or selected not in {route.id for route in routes.routes}:
|
|
yield self._failure_event(
|
|
inputs=inputs,
|
|
process_data=process_data,
|
|
metadata=metadata,
|
|
error="Agent output switch must select one configured output route ID.",
|
|
error_type="output_route_selection_failed",
|
|
)
|
|
return
|
|
edge_source_handle = selected
|
|
|
|
yield StreamCompletedEvent(
|
|
node_run_result=self._output_adapter.build_success_result(
|
|
event=success_event,
|
|
edge_source_handle=edge_source_handle,
|
|
inputs=inputs,
|
|
process_data=process_data,
|
|
metadata=metadata,
|
|
declared_outputs=custom_outputs,
|
|
)
|
|
)
|
|
return
|
|
|
|
def _start_or_resume_confirmed(self, *, dify_context, session_scope, stored_session, process_data):
|
|
from runtime_contracts.builder.execution import BuilderExecutionPolicyError, canonical_digest
|
|
from runtime_contracts.builder.native.agent_contracts import AgentCreateInserted, AgentParentNodeRef
|
|
from services.agent.workflow_run_recovery import (
|
|
AgentCreateAcknowledgementUnknown,
|
|
AgentCreateRecoveryClient,
|
|
AgentCreateScope,
|
|
)
|
|
from services.dify_builder.native_testing.agent_confirmed_workspace import (
|
|
load_confirmed_agent_parent,
|
|
prepare_confirmed_agent_request,
|
|
)
|
|
|
|
marker = dify_context.builder_agent_execution
|
|
assert marker is not None
|
|
assert self._agent_preparation is not None
|
|
assert self._agent_commands is not None
|
|
assert self._agent_run_recovery is not None
|
|
scope = AgentCreateScope(
|
|
tenant_id=session_scope.tenant_id,
|
|
app_id=session_scope.app_id,
|
|
workflow_id=session_scope.workflow_id,
|
|
workflow_run_id=session_scope.workflow_run_id,
|
|
node_id=self._node_id,
|
|
execution_id=self.execution_id,
|
|
workflow_binding_id=session_scope.workflow_agent_binding_id,
|
|
)
|
|
client = self._agent_backend_client
|
|
assert isinstance(client, AgentCreateRecoveryClient)
|
|
with _confirmed_node_phase(marker, "parent_restore"):
|
|
parent = load_confirmed_agent_parent(
|
|
state_store=self._agent_preparation.state_store, ref=marker.ref, execution_id=self.execution_id
|
|
)
|
|
with _confirmed_node_phase(marker, "recover_existing"):
|
|
response = self._agent_run_recovery.recover_existing(
|
|
scope=scope,
|
|
client=client,
|
|
on_persisted=process_data.update,
|
|
)
|
|
if parent is not None:
|
|
if response is None:
|
|
# A previously prepared occurrence cannot acquire first-POST
|
|
# authority merely because another process stopped before ACK.
|
|
raise AgentCreateAcknowledgementUnknown("Agent create acknowledgement is unknown")
|
|
with _confirmed_node_phase(marker, "load_wait"):
|
|
protected = self._agent_commands.load_wait(ref=marker.ref, parent_node=parent)
|
|
if protected is not None:
|
|
if protected.association.agent_run_id != response.run_id:
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
with _confirmed_node_phase(marker, "continue_wait"):
|
|
continued = self._agent_commands.continue_wait(ref=marker.ref, parent_node=parent)
|
|
if continued.association.agent_run_id != response.run_id or continued.wait is None:
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
identity = self._capture_confirmed_snapshot(
|
|
parent=parent,
|
|
response=response,
|
|
stored_session=stored_session,
|
|
agent_generation=continued.wait.agent_generation + 1,
|
|
)
|
|
return response, parent, continued.wait.backend_wait_event_id, identity
|
|
identity = self._capture_confirmed_snapshot(
|
|
parent=parent,
|
|
response=response,
|
|
stored_session=stored_session,
|
|
agent_generation=0,
|
|
)
|
|
return response, parent, None, identity
|
|
if response is not None:
|
|
raise BuilderExecutionPolicyError("native_agent_input_unavailable")
|
|
context = self._agent_preparation.state_store.load_context(ref=marker.ref)
|
|
builder = self._agent_preparation.config_builder
|
|
if builder is None:
|
|
raise BuilderExecutionPolicyError("native_agent_preparation_unavailable")
|
|
definition = builder.load_definition(context=context, node_id=self._node_id)
|
|
typed_inputs = builder.typed_inputs(
|
|
runtime_state=self.runtime_state,
|
|
definition=definition,
|
|
)
|
|
input_ref = self._agent_preparation.state_store.store_node_inputs(
|
|
ref=marker.ref,
|
|
generation=context.generation,
|
|
native_execution_id=self.execution_id,
|
|
inputs=typed_inputs,
|
|
)
|
|
parent = AgentParentNodeRef(
|
|
ref=marker.ref,
|
|
execution_id=self.execution_id,
|
|
input_result_ref=input_ref,
|
|
input_digest=canonical_digest(typed_inputs),
|
|
)
|
|
prepared = prepare_confirmed_agent_request(
|
|
service=self._agent_preparation,
|
|
ref=marker.ref,
|
|
parent_node=parent,
|
|
node_id=self._node_id,
|
|
stored_session=stored_session,
|
|
inputs=typed_inputs,
|
|
)
|
|
if not isinstance(prepared.claim, AgentCreateInserted):
|
|
raise AgentCreateAcknowledgementUnknown("Agent create acknowledgement is unknown")
|
|
response = self._agent_run_recovery.start_or_recover(
|
|
scope=scope,
|
|
attempt=0,
|
|
request=prepared.request,
|
|
client=client,
|
|
on_persisted=process_data.update,
|
|
)
|
|
identity = self._capture_confirmed_snapshot(
|
|
parent=parent,
|
|
response=response,
|
|
stored_session=stored_session,
|
|
agent_generation=0,
|
|
)
|
|
return response, parent, None, identity
|
|
|
|
def _capture_confirmed_snapshot(self, *, parent, response, stored_session, agent_generation):
|
|
"""Obtain acknowledged association and capture this stream's original tuple.
|
|
|
|
Initial bind uses the existing runtime identity owner, never an invented
|
|
association ref. Failure refuses persistence while stream/cancellation
|
|
retain their existing independent behavior.
|
|
"""
|
|
from dify_agent.protocol.confirmed_recovery import AgentRecoveryBinding
|
|
from dify_agent.protocol.native_testing import AgentParentNodeRef as WireParent
|
|
from dify_agent.protocol.native_testing import AgentPrepareRunPayload
|
|
|
|
from runtime_contracts.builder.execution import BuilderExecutionPolicyError
|
|
|
|
assert self._agent_preparation is not None
|
|
assert self._agent_commands is not None
|
|
try:
|
|
store = self._agent_preparation.state_store
|
|
context = store.load_context(ref=parent.ref)
|
|
config = store.load_prepared_config(ref=parent.ref, parent_node=parent, config_ref=None)
|
|
try:
|
|
protected = store.load_agent_association(ref=parent.ref, parent_node=parent)
|
|
except BuilderExecutionPolicyError as error:
|
|
if error.reason_code != "native_agent_object_unavailable" or agent_generation != 0:
|
|
raise
|
|
self._agent_preparation.bind_created_run(
|
|
payload=AgentPrepareRunPayload(
|
|
parent_node=WireParent.model_validate(parent.model_dump(mode="json")),
|
|
agent_run_id=response.run_id,
|
|
request_digest=config.config_digest,
|
|
)
|
|
)
|
|
protected = store.load_agent_association(ref=parent.ref, parent_node=parent)
|
|
workspace = protected.association.workspace
|
|
if (stored_session.binding_id, stored_session.workspace_id, stored_session.backend_binding_ref) != (
|
|
workspace.binding_id,
|
|
workspace.workspace_id,
|
|
workspace.backend_binding_ref,
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_snapshot_identity_mismatch")
|
|
return self._agent_commands.capture_session_snapshot_identity(
|
|
parent_node=parent,
|
|
parent_generation=context.generation,
|
|
agent_run_id=response.run_id,
|
|
agent_generation=agent_generation,
|
|
binding=AgentRecoveryBinding(
|
|
binding_id=workspace.binding_id,
|
|
workspace_id=workspace.workspace_id,
|
|
binding_ref=workspace.backend_binding_ref,
|
|
workspace_ref=workspace.backend_workspace_ref,
|
|
workspace_generation=workspace.workspace_generation,
|
|
association_object_ref=protected.association_object_ref,
|
|
config_ref=config.config_ref,
|
|
config_digest=config.config_digest,
|
|
),
|
|
)
|
|
except Exception as error:
|
|
logger.warning(
|
|
"Failed to capture confirmed Agent session snapshot identity: "
|
|
"request_id=%s node_execution_id=%s agent_run_id=%s error_type=%s",
|
|
parent.ref.request_id,
|
|
parent.execution_id,
|
|
response.run_id,
|
|
type(error).__name__,
|
|
)
|
|
return None
|
|
|
|
def _control_pause(self, inputs, process_data, metadata, reason):
|
|
metadata["agent_backend"] = {**dict(metadata.get("agent_backend") or {}), "status": reason}
|
|
return NodeRunPauseRequestedEvent(
|
|
id=self.execution_id,
|
|
node_id=self._node_id,
|
|
node_type=self.node_type,
|
|
node_version=self.version(),
|
|
reason=SchedulingPause(message=reason),
|
|
node_run_result=NodeRunResult(
|
|
status=WorkflowNodeExecutionStatus.PAUSED,
|
|
inputs=inputs,
|
|
process_data=process_data,
|
|
outputs={},
|
|
metadata={WorkflowNodeExecutionMetadataKey.AGENT_LOG: metadata},
|
|
),
|
|
)
|
|
|
|
def _consume_event_stream(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
inputs: dict[str, Any],
|
|
process_data: dict[str, Any],
|
|
metadata: dict[str, Any],
|
|
stream_text: bool,
|
|
after: str | None = None,
|
|
) -> Generator[
|
|
StreamChunkEvent,
|
|
None,
|
|
tuple[_TerminalAgentBackendEvent | None, StreamCompletedEvent | None],
|
|
]:
|
|
"""Consume the SSE stream for one Agent backend run.
|
|
|
|
When ``stream_text`` is set (plain runs whose final string becomes the
|
|
``text`` output), agent-message deltas are yielded as ``text`` stream
|
|
chunks so Answer nodes can stream them. The stream is closed only after
|
|
at least one chunk was sent; otherwise the response coordinator falls
|
|
back to the final ``text`` variable. Structured runs keep deltas as
|
|
metadata because their ``text`` comes from the structured output.
|
|
Plain runs with the default-value error strategy stay buffered too:
|
|
once a selector has stream chunks, the response filter no longer reads
|
|
the variable pool, so a configured fallback ``text`` would be dropped
|
|
after a partial stream.
|
|
|
|
Returns a 2-tuple ``(terminal_event, transport_failure)``:
|
|
- ``terminal_event``: the first non-stream/non-started internal event,
|
|
or ``None`` if the stream ended without one.
|
|
- ``transport_failure``: a populated ``StreamCompletedEvent`` when the
|
|
stream itself errored (backend/HTTP/protocol fault). A cancellation
|
|
terminal may accompany it so the caller can persist the final session
|
|
snapshot while preserving the original transport failure.
|
|
"""
|
|
result = yield from self._read_event_stream(
|
|
run_id,
|
|
inputs=inputs,
|
|
process_data=process_data,
|
|
metadata=metadata,
|
|
stream_text=stream_text,
|
|
after=after,
|
|
)
|
|
if (
|
|
not isinstance(result[0], AgentBackendRunWaitingInternalEvent)
|
|
and stream_text
|
|
and (metadata.get("agent_backend") or {}).get("agent_message_delta_count")
|
|
):
|
|
yield StreamChunkEvent(selector=[self._node_id, "text"], chunk="", is_final=True)
|
|
return result
|
|
|
|
def _read_event_stream(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
inputs: dict[str, Any],
|
|
process_data: dict[str, Any],
|
|
metadata: dict[str, Any],
|
|
stream_text: bool,
|
|
after: str | None = None,
|
|
) -> Generator[
|
|
StreamChunkEvent,
|
|
None,
|
|
tuple[_TerminalAgentBackendEvent | None, StreamCompletedEvent | None],
|
|
]:
|
|
stream_event_count = 0
|
|
last_event_id: str | None = None
|
|
confirmed = (
|
|
DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY)).builder_agent_execution
|
|
is not None
|
|
)
|
|
try:
|
|
for public_event in self._agent_backend_client.stream_events(
|
|
run_id,
|
|
should_stop=self._is_graph_aborted,
|
|
**({"after": after} if after is not None else {}),
|
|
):
|
|
stream_event_count += 1
|
|
if public_event.id is not None:
|
|
last_event_id = public_event.id
|
|
for internal_event in self._event_adapter.adapt(public_event):
|
|
if internal_event.type in (
|
|
AgentBackendInternalEventType.RUN_STARTED,
|
|
AgentBackendInternalEventType.RUN_RESUMED,
|
|
):
|
|
continue
|
|
if internal_event.type == AgentBackendInternalEventType.STREAM_EVENT:
|
|
if isinstance(internal_event, AgentBackendStreamInternalEvent):
|
|
self._record_stream_metadata(metadata, internal_event)
|
|
continue
|
|
if internal_event.type == AgentBackendInternalEventType.AGENT_MESSAGE_DELTA:
|
|
if isinstance(internal_event, AgentBackendAgentMessageDeltaInternalEvent):
|
|
self._record_agent_message_delta_metadata(metadata, internal_event)
|
|
if stream_text:
|
|
yield StreamChunkEvent(
|
|
selector=[self._node_id, "text"],
|
|
chunk=internal_event.delta,
|
|
is_final=False,
|
|
)
|
|
continue
|
|
metadata["agent_backend"] = {
|
|
**dict(metadata.get("agent_backend") or {}),
|
|
"stream_event_count": stream_event_count,
|
|
}
|
|
# Known waiting is control only on the explicit confirmed path.
|
|
if isinstance(internal_event, AgentBackendRunWaitingInternalEvent) and confirmed:
|
|
return internal_event, None
|
|
if isinstance(
|
|
internal_event,
|
|
AgentBackendRunSucceededInternalEvent
|
|
| AgentBackendRunFailedInternalEvent
|
|
| AgentBackendRunCancelledInternalEvent,
|
|
):
|
|
return internal_event, None
|
|
cancellation = (
|
|
None
|
|
if confirmed
|
|
else self._cancel_backend_run(
|
|
run_id,
|
|
reason="unexpected_event",
|
|
after=last_event_id,
|
|
)
|
|
)
|
|
return cancellation, self._failure_event(
|
|
inputs=inputs,
|
|
process_data=process_data,
|
|
metadata=metadata,
|
|
error=f"Unexpected internal event type {internal_event.type!r}",
|
|
error_type="agent_backend_stream_error",
|
|
)
|
|
except AgentBackendError as error:
|
|
cancellation = (
|
|
None
|
|
if confirmed and not self._is_graph_aborted()
|
|
else self._cancel_backend_run(
|
|
run_id,
|
|
reason=self._stream_stop_reason(),
|
|
after=last_event_id,
|
|
)
|
|
)
|
|
return cancellation, self._failure_event(
|
|
inputs=inputs,
|
|
process_data=process_data,
|
|
metadata=metadata,
|
|
error=str(error),
|
|
error_type=self._agent_backend_error_type(error),
|
|
)
|
|
except Exception as error:
|
|
cancellation = (
|
|
None
|
|
if confirmed and not self._is_graph_aborted()
|
|
else self._cancel_backend_run(
|
|
run_id,
|
|
reason=self._stream_stop_reason(),
|
|
after=last_event_id,
|
|
)
|
|
)
|
|
return cancellation, self._failure_event(
|
|
inputs=inputs,
|
|
process_data=process_data,
|
|
metadata=metadata,
|
|
error=str(error),
|
|
error_type="agent_backend_stream_error",
|
|
)
|
|
|
|
cancellation = (
|
|
None
|
|
if confirmed and not self._is_graph_aborted()
|
|
else self._cancel_backend_run(
|
|
run_id,
|
|
reason=self._stream_stop_reason()
|
|
if self._is_graph_aborted()
|
|
else "stream_ended_without_terminal_event",
|
|
after=last_event_id,
|
|
)
|
|
)
|
|
return cancellation, None
|
|
|
|
def _is_graph_aborted(self) -> bool:
|
|
"""Let Agent SSE consumption observe Engine's cooperative abort state."""
|
|
return self.runtime_state.graph_execution.aborted
|
|
|
|
def _stream_stop_reason(self) -> str:
|
|
return "workflow_graph_aborted" if self._is_graph_aborted() else "event_stream_failed"
|
|
|
|
def _cancel_backend_run(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
reason: str,
|
|
after: str | None,
|
|
) -> AgentBackendRunCancelledInternalEvent | None:
|
|
try:
|
|
public_event = self._agent_backend_client.cancel_run_and_wait(
|
|
run_id,
|
|
CancelRunRequest(reason=reason, message="Workflow Agent event consumption stopped"),
|
|
after=after,
|
|
)
|
|
for internal_event in self._event_adapter.adapt(public_event):
|
|
if isinstance(internal_event, AgentBackendRunCancelledInternalEvent):
|
|
return internal_event
|
|
except Exception:
|
|
logger.warning("Failed to finish cancelling Workflow Agent backend run: run_id=%s", run_id, exc_info=True)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _record_type_check_metadata(metadata: dict[str, Any], outcome: OutputTypeCheckOutcome) -> None:
|
|
# Surface enough detail in metadata for Inspector / debug logs without
|
|
# leaking the raw failing values (which may be sensitive).
|
|
metadata["output_type_check"] = {
|
|
"passed": not outcome.has_failures,
|
|
"results": [
|
|
{
|
|
"name": r.name,
|
|
"type": r.declared_type.value,
|
|
"status": r.status.value,
|
|
"reason": r.reason,
|
|
}
|
|
for r in outcome.results
|
|
],
|
|
}
|
|
|
|
def _save_session_snapshot(
|
|
self,
|
|
*,
|
|
session_scope: WorkflowAgentSessionScope,
|
|
binding_id: str,
|
|
snapshot: SessionSnapshot | None,
|
|
metadata: dict[str, Any],
|
|
confirmed_identity: ConfirmedSnapshotIdentity | None = None,
|
|
) -> None:
|
|
try:
|
|
context = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
|
|
if context.builder_agent_execution is not None:
|
|
from runtime_contracts.builder.execution import BuilderExecutionPolicyError
|
|
|
|
if self._agent_commands is None or confirmed_identity is None:
|
|
raise BuilderExecutionPolicyError("native_agent_snapshot_identity_unavailable")
|
|
if (confirmed_identity.parent_node.ref, confirmed_identity.binding.binding_id) != (
|
|
context.builder_agent_execution.ref,
|
|
binding_id,
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_snapshot_identity_mismatch")
|
|
self._agent_commands.save_session_snapshot(
|
|
identity=confirmed_identity,
|
|
session_scope=session_scope,
|
|
snapshot=snapshot,
|
|
)
|
|
else:
|
|
self._session_store.save_active_snapshot(
|
|
scope=session_scope,
|
|
binding_id=binding_id,
|
|
snapshot=snapshot,
|
|
)
|
|
agent_backend = dict(metadata.get("agent_backend") or {})
|
|
agent_backend["session_snapshot_persisted"] = snapshot is not None
|
|
metadata["agent_backend"] = agent_backend
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to persist workflow Agent Binding session snapshot: "
|
|
"tenant_id=%s workflow_run_id=%s node_id=%s binding_id=%s agent_id=%s",
|
|
session_scope.tenant_id,
|
|
session_scope.workflow_run_id,
|
|
session_scope.node_id,
|
|
session_scope.workflow_agent_binding_id,
|
|
session_scope.agent_id,
|
|
exc_info=True,
|
|
)
|
|
agent_backend = dict(metadata.get("agent_backend") or {})
|
|
agent_backend["session_snapshot_persisted"] = False
|
|
agent_backend["session_snapshot_persist_error"] = "workflow_agent_workspace_store_error"
|
|
metadata["agent_backend"] = agent_backend
|
|
|
|
@staticmethod
|
|
def _patch_event_with_defaults(
|
|
event: AgentBackendRunSucceededInternalEvent,
|
|
per_output_actions: Mapping[str, Any],
|
|
) -> AgentBackendRunSucceededInternalEvent:
|
|
"""Merge USE_DEFAULT replacements into the success event's output dict.
|
|
|
|
The event is a frozen dataclass / Pydantic model; we copy with the
|
|
replacements applied so downstream code (output_adapter normalize) sees
|
|
the patched payload.
|
|
"""
|
|
if not per_output_actions:
|
|
return event
|
|
original = event.output if isinstance(event.output, Mapping) else {}
|
|
patched_output: dict[str, Any] = dict(original)
|
|
patched_output.update(per_output_actions)
|
|
return event.model_copy(update={"output": patched_output})
|
|
|
|
@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):
|
|
sources = knowledge_sources_from_stream_event(event.data)
|
|
if sources:
|
|
current = metadata.setdefault("knowledge_fs_sources", [])
|
|
seen = {source["knowledge_fs_citation"]["id"] for source in current}
|
|
for source in sources:
|
|
if (
|
|
source.knowledge_fs_citation
|
|
and source.knowledge_fs_citation.id not in seen
|
|
and len(current) < 200
|
|
):
|
|
current.append(source.model_dump(mode="json"))
|
|
seen.add(source.knowledge_fs_citation.id)
|
|
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
|
|
|
|
@staticmethod
|
|
def _record_agent_message_delta_metadata(
|
|
metadata: dict[str, Any], event: AgentBackendAgentMessageDeltaInternalEvent
|
|
) -> None:
|
|
agent_backend = dict(metadata.get("agent_backend") or {})
|
|
agent_backend["agent_message_delta_count"] = int(agent_backend.get("agent_message_delta_count") or 0) + 1
|
|
agent_backend["agent_message_delta_length"] = int(agent_backend.get("agent_message_delta_length") or 0) + len(
|
|
event.delta
|
|
)
|
|
metadata["agent_backend"] = agent_backend
|
|
|
|
@classmethod
|
|
@override
|
|
def _extract_variable_selector_to_variable_mapping(
|
|
cls,
|
|
*,
|
|
graph_config: Mapping[str, Any],
|
|
node_id: str,
|
|
node_data: DifyAgentNodeData,
|
|
) -> Mapping[str, Sequence[str]]:
|
|
"""Reuse frontend workflow-marker parsing for graph variable loading.
|
|
|
|
Task mentions use the publish parser. Enabled route conditions use
|
|
Graphon template selectors, including environment and system variables;
|
|
disabled jobs do not load their saved conditions.
|
|
"""
|
|
del graph_config
|
|
|
|
if isinstance(node_data, Mapping):
|
|
task = node_data.get("agent_task", "")
|
|
routes = WorkflowOutputRoutes.model_validate(node_data.get("agent_output_routes", {}))
|
|
else:
|
|
task = node_data.agent_task
|
|
routes = node_data.agent_output_routes
|
|
selectors = list(extract_workflow_node_output_selectors(task))
|
|
if routes.enabled:
|
|
for route in routes.routes:
|
|
selectors.extend(
|
|
tuple(selector.value_selector)
|
|
for selector in VariableTemplateParser(template=route.name).extract_variable_selectors()
|
|
)
|
|
return {f"{node_id}.{'.'.join(selector)}": list(selector) for selector in selectors}
|