mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 13:51:25 +08:00
347 lines
16 KiB
Python
347 lines
16 KiB
Python
"""Native Agent admission in the existing caller-owned draft transaction.
|
|
|
|
Call prepare_node for every selected node before installing its graph data, then
|
|
sync_prepared_bindings in the SAME transaction. The authoritative roster row is
|
|
locked until graph and native bindings commit/roll back together. No method here
|
|
commits, dispatches a capability, or materializes runtime credentials.
|
|
"""
|
|
|
|
from collections.abc import Mapping
|
|
from copy import deepcopy
|
|
from dataclasses import dataclass
|
|
|
|
from pydantic import JsonValue
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from core.agent.publish_visibility import workflow_callable_active_snapshot_filter
|
|
from core.dify_builder.models import Actor
|
|
from core.workflow.graph_topology import WorkflowGraphTopology
|
|
from core.workflow.nodes.agent_v2.entities import DifyAgentNodeData
|
|
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator
|
|
from models.agent import (
|
|
Agent,
|
|
AgentConfigSnapshot,
|
|
AgentScope,
|
|
AgentStatus,
|
|
WorkflowAgentBindingType,
|
|
WorkflowAgentNodeBinding,
|
|
)
|
|
from models.agent_config_entities import WorkflowNodeJobConfig
|
|
from models.workflow import Workflow
|
|
from runtime_contracts.builder.execution import BuilderExecutionPolicyError, Digest
|
|
from runtime_contracts.builder.native.contracts import ID, FrozenDTO
|
|
from services.agent import access_service
|
|
from services.agent.composer_validator import ComposerConfigValidator
|
|
from services.agent.workflow_publish_service import WorkflowAgentPublishService
|
|
from services.dify_builder.native_testing.agent_catalog import project_published_agent
|
|
|
|
|
|
class AgentSnapshotChanged(ValueError): # noqa: N818 - frozen A1 domain contract
|
|
def __init__(self, *, effective_snapshot_id: str):
|
|
self.effective_snapshot_id = effective_snapshot_id
|
|
super().__init__("Published Agent snapshot changed; reconfirm the selection.")
|
|
|
|
|
|
def require_selected_snapshot(*, selected_snapshot_id: str, effective_snapshot_id: str) -> None:
|
|
if selected_snapshot_id != effective_snapshot_id:
|
|
raise AgentSnapshotChanged(effective_snapshot_id=effective_snapshot_id)
|
|
|
|
|
|
class AgentBindingSelection(FrozenDTO):
|
|
agent_id: ID
|
|
snapshot_id: ID
|
|
capability_digest: Digest
|
|
|
|
@classmethod
|
|
def from_resource_id(cls, resource_id: str) -> "AgentBindingSelection":
|
|
prefix, agent_id, snapshot_id, digest = resource_id.split(":")
|
|
if prefix != "agent":
|
|
raise ValueError("Invalid Agent selection")
|
|
return cls(agent_id=agent_id, snapshot_id=snapshot_id, capability_digest=digest)
|
|
|
|
|
|
def native_node_data(*, agent_id: str, snapshot_id: str, node_job: WorkflowNodeJobConfig) -> dict[str, JsonValue]:
|
|
"""Render only native graph fields consumed by the publication owner."""
|
|
return {
|
|
"type": "agent",
|
|
"version": "2",
|
|
"agent_node_kind": "dify_agent",
|
|
"title": "Workspace Agent",
|
|
"agent_binding": {"binding_type": "roster_agent", "agent_id": agent_id, "current_snapshot_id": snapshot_id},
|
|
"agent_task": node_job.workflow_prompt,
|
|
"agent_declared_outputs": [item.model_dump(mode="json") for item in node_job.declared_outputs],
|
|
"agent_output_routes": node_job.output_routes.model_dump(mode="json"),
|
|
}
|
|
|
|
|
|
def has_native_roster_binding(data: Mapping) -> bool:
|
|
binding = data.get("agent_binding")
|
|
return (
|
|
data.get("type") == "agent"
|
|
and data.get("version") == "2"
|
|
and data.get("agent_node_kind") == "dify_agent"
|
|
and isinstance(binding, Mapping)
|
|
and binding.get("binding_type") == "roster_agent"
|
|
and isinstance(binding.get("agent_id"), str)
|
|
and bool(binding["agent_id"])
|
|
and isinstance(binding.get("current_snapshot_id"), str)
|
|
and bool(binding["current_snapshot_id"])
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AgentBindingAuthorization:
|
|
"""Ephemeral current permission result; never a persisted/public authority."""
|
|
|
|
actor_id: str
|
|
tenant_id: str
|
|
app_id: str
|
|
agent_ids: frozenset[str]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ConfirmedAgentNodeSelection:
|
|
agent_id: str
|
|
snapshot_id: str
|
|
authorization: AgentBindingAuthorization
|
|
|
|
|
|
def authorize_graph_agent_selection(*, actor: Actor, app_id: str, node_data: Mapping) -> ConfirmedAgentNodeSelection:
|
|
"""Authorize the exact native graph selection before opening resolver SQL.
|
|
|
|
This is only a current permission check. The binding resolver must still
|
|
verify the complete owner chain and selected generation at execution.
|
|
"""
|
|
if not has_native_roster_binding(node_data):
|
|
raise BuilderExecutionPolicyError("agent_binding_not_prepared")
|
|
binding = node_data["agent_binding"]
|
|
agent_id, snapshot_id = binding["agent_id"], binding["current_snapshot_id"]
|
|
authorization = AgentBindingService.authorize(actor=actor, app_id=app_id, agent_ids=frozenset({agent_id}))
|
|
return ConfirmedAgentNodeSelection(agent_id, snapshot_id, authorization)
|
|
|
|
|
|
class AgentBindingService:
|
|
def __init__(self, session: Session, *, authorization: AgentBindingAuthorization | None = None):
|
|
self.session = session
|
|
self.authorization = authorization
|
|
self._preparation_transaction = None
|
|
self._prepared: dict[
|
|
tuple[str, str], tuple[AgentBindingSelection, dict[str, JsonValue], WorkflowNodeJobConfig]
|
|
] = {}
|
|
|
|
@staticmethod
|
|
def authorize(*, actor: Actor, app_id: str, agent_ids: frozenset[str]) -> AgentBindingAuthorization:
|
|
"""Call immediately BEFORE opening the bounded draft transaction.
|
|
|
|
RBAC RPC runs outside DB transactions/row locks. Local membership and
|
|
publication are checked again in prepare_node under caller ownership.
|
|
"""
|
|
from configs import dify_config
|
|
from core.rbac import RBACPermission, RBACResourceScope
|
|
from services.enterprise.rbac_service import RBACService
|
|
|
|
if dify_config.RBAC_ENABLED:
|
|
if not RBACService.CheckAccess.check(
|
|
actor.tenant_id,
|
|
actor.account_id,
|
|
scene=RBACPermission.APP_EDIT,
|
|
resource_type=RBACResourceScope.APP,
|
|
resource_id=app_id,
|
|
):
|
|
raise BuilderExecutionPolicyError("agent_access_denied")
|
|
access = access_service.resolve_current_agent_access_filter(
|
|
tenant_id=actor.tenant_id, actor_id=actor.account_id
|
|
)
|
|
for agent_id in agent_ids:
|
|
if (
|
|
access.accessible_agent_ids is not None and agent_id not in access.accessible_agent_ids
|
|
) or not RBACService.CheckAccess.check(
|
|
actor.tenant_id,
|
|
actor.account_id,
|
|
scene=RBACPermission.AGENT_PREVIEW,
|
|
resource_type=RBACResourceScope.AGENT,
|
|
resource_id=agent_id,
|
|
):
|
|
raise BuilderExecutionPolicyError("agent_access_denied")
|
|
return AgentBindingAuthorization(actor.account_id, actor.tenant_id, app_id, agent_ids)
|
|
|
|
def recheck_access(self, *, actor: Actor, app_id: str, agent_id: str) -> None:
|
|
"""Check the pre-transaction RBAC result plus current local identity."""
|
|
from configs import dify_config
|
|
|
|
grant = self.authorization
|
|
if dify_config.RBAC_ENABLED and (
|
|
grant is None
|
|
or grant.actor_id != actor.account_id
|
|
or grant.tenant_id != actor.tenant_id
|
|
or grant.app_id != app_id
|
|
or agent_id not in grant.agent_ids
|
|
):
|
|
raise BuilderExecutionPolicyError("agent_access_denied")
|
|
access_service.require_builder_agent_access(
|
|
session=self.session, tenant_id=actor.tenant_id, actor_id=actor.account_id, app_id=app_id
|
|
)
|
|
|
|
def prepare_node(
|
|
self,
|
|
*,
|
|
actor: Actor,
|
|
workflow: Workflow,
|
|
node_id: str,
|
|
selection: AgentBindingSelection,
|
|
node_job: WorkflowNodeJobConfig,
|
|
existing_node_data: Mapping[str, JsonValue] | None = None,
|
|
) -> dict[str, JsonValue]:
|
|
if not self.session.in_transaction():
|
|
raise ValueError("Agent preparation requires the caller's draft transaction")
|
|
if self._prepared and self._preparation_transaction is not self.session.get_transaction():
|
|
raise BuilderExecutionPolicyError("agent_binding_not_prepared")
|
|
self._preparation_transaction = self.session.get_transaction()
|
|
if workflow.tenant_id != actor.tenant_id or workflow.version != Workflow.VERSION_DRAFT or not node_id:
|
|
raise BuilderExecutionPolicyError("agent_binding_owner_mismatch")
|
|
owned_workflow = self.session.scalar(
|
|
select(Workflow).where(
|
|
Workflow.id == workflow.id,
|
|
Workflow.tenant_id == actor.tenant_id,
|
|
Workflow.app_id == workflow.app_id,
|
|
Workflow.version == Workflow.VERSION_DRAFT,
|
|
)
|
|
)
|
|
if owned_workflow is None:
|
|
raise BuilderExecutionPolicyError("agent_binding_owner_mismatch")
|
|
self.recheck_access(actor=actor, app_id=workflow.app_id, agent_id=selection.agent_id)
|
|
# Refresh the identity map after acquiring the publication pointer lock.
|
|
agent = self.session.scalar(
|
|
select(Agent)
|
|
.where(
|
|
Agent.id == selection.agent_id,
|
|
Agent.tenant_id == actor.tenant_id,
|
|
Agent.scope == AgentScope.ROSTER,
|
|
Agent.status == AgentStatus.ACTIVE,
|
|
Agent.active_config_has_model.is_(True),
|
|
workflow_callable_active_snapshot_filter(),
|
|
)
|
|
.with_for_update()
|
|
.execution_options(populate_existing=True)
|
|
)
|
|
if agent is None or agent.active_config_snapshot_id is None:
|
|
raise BuilderExecutionPolicyError("agent_not_available")
|
|
access_service.require_builder_agent_access(
|
|
session=self.session, tenant_id=actor.tenant_id, actor_id=actor.account_id, app_id=workflow.app_id
|
|
)
|
|
require_selected_snapshot(
|
|
selected_snapshot_id=selection.snapshot_id, effective_snapshot_id=agent.active_config_snapshot_id
|
|
)
|
|
snapshot = self.session.scalar(
|
|
select(AgentConfigSnapshot).where(
|
|
AgentConfigSnapshot.id == selection.snapshot_id,
|
|
AgentConfigSnapshot.tenant_id == actor.tenant_id,
|
|
AgentConfigSnapshot.agent_id == agent.id,
|
|
)
|
|
)
|
|
if snapshot is None:
|
|
raise BuilderExecutionPolicyError("agent_not_available")
|
|
if project_published_agent(agent=agent, snapshot=snapshot).capability_digest != selection.capability_digest:
|
|
raise BuilderExecutionPolicyError("agent_capability_changed")
|
|
node_job = WorkflowNodeJobConfig.model_validate(node_job.model_dump(mode="json"))
|
|
ComposerConfigValidator.validate_node_job(node_job)
|
|
# The native graph owner derives input refs from task markers on sync.
|
|
derived = WorkflowAgentPublishService._previous_node_output_refs_from_prompt(node_job.workflow_prompt)
|
|
if node_job.previous_node_output_refs and node_job.previous_node_output_refs != derived:
|
|
raise ValueError("Node-job input selectors must match native task references")
|
|
node_job.previous_node_output_refs = derived
|
|
data = native_node_data(agent_id=agent.id, snapshot_id=snapshot.id, node_job=node_job)
|
|
if existing_node_data is not None:
|
|
# The caller has retained this exact same-node configuration under
|
|
# its draft/binding locks. Keep generic retry/error behavior and
|
|
# canvas data, while the native projection remains owned here.
|
|
if any(existing_node_data.get(key) != value for key, value in data.items() if key != "title"):
|
|
raise BuilderExecutionPolicyError("agent_binding_not_prepared")
|
|
data = deepcopy(dict(existing_node_data))
|
|
DifyAgentNodeData.model_validate(data)
|
|
binding = WorkflowAgentNodeBinding(
|
|
tenant_id=actor.tenant_id,
|
|
app_id=workflow.app_id,
|
|
workflow_id=workflow.id,
|
|
workflow_version=workflow.version,
|
|
node_id=node_id,
|
|
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
|
|
agent_id=agent.id,
|
|
current_snapshot_id=snapshot.id,
|
|
node_job_config=node_job,
|
|
)
|
|
graph = deepcopy(dict(workflow.graph_dict))
|
|
nodes = graph.setdefault("nodes", [])
|
|
nodes[:] = [item for item in nodes if item.get("id") != node_id]
|
|
nodes.append({"id": node_id, "data": data})
|
|
WorkflowAgentNodeValidator.validate_node_job(
|
|
session=self.session, binding=binding, node_job=node_job, topology=WorkflowGraphTopology.from_graph(graph)
|
|
)
|
|
self._prepared[(workflow.id, node_id)] = (selection, deepcopy(data), node_job.model_copy(deep=True))
|
|
return data
|
|
|
|
def sync_prepared_bindings(
|
|
self, *, actor: Actor, workflow: Workflow, selections: Mapping[str, AgentBindingSelection]
|
|
) -> set[str]:
|
|
"""Require preparation for the complete graph before whole-graph native sync.
|
|
|
|
Every native graph Agent must have a confirmed prepared roster selection.
|
|
Existing bindings omitted from the graph are protected from implicit
|
|
deletion. Inline/unprepared nodes and removals require their own admitted
|
|
owner path rather than piggybacking on this roster binding operation.
|
|
"""
|
|
if not self.session.in_transaction():
|
|
raise ValueError("Agent sync requires the caller's draft transaction")
|
|
if self.session.get_transaction() is not self._preparation_transaction:
|
|
raise BuilderExecutionPolicyError("agent_binding_not_prepared")
|
|
nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(workflow.graph_dict))
|
|
existing_node_ids = set(
|
|
self.session.scalars(
|
|
select(WorkflowAgentNodeBinding.node_id).where(
|
|
WorkflowAgentNodeBinding.tenant_id == actor.tenant_id,
|
|
WorkflowAgentNodeBinding.app_id == workflow.app_id,
|
|
WorkflowAgentNodeBinding.workflow_id == workflow.id,
|
|
WorkflowAgentNodeBinding.workflow_version == workflow.version,
|
|
)
|
|
).all()
|
|
)
|
|
prepared_node_ids = {node_id for workflow_id, node_id in self._prepared if workflow_id == workflow.id}
|
|
if (
|
|
not selections
|
|
or set(selections) != set(nodes)
|
|
or prepared_node_ids != set(nodes)
|
|
or not existing_node_ids.issubset(nodes)
|
|
):
|
|
raise BuilderExecutionPolicyError("agent_binding_not_prepared")
|
|
for node_id, selection in selections.items():
|
|
prepared = self._prepared.get((workflow.id, node_id))
|
|
if prepared is None or prepared[0] != selection or nodes.get(node_id) != prepared[1]:
|
|
raise BuilderExecutionPolicyError("agent_binding_not_prepared")
|
|
self.recheck_access(actor=actor, app_id=workflow.app_id, agent_id=selection.agent_id)
|
|
retirements = WorkflowAgentPublishService.sync_agent_bindings_for_draft(
|
|
session=self.session, draft_workflow=workflow, account_id=actor.account_id
|
|
)
|
|
for node_id, selection in selections.items():
|
|
binding = self.session.scalar(
|
|
select(WorkflowAgentNodeBinding).where(
|
|
WorkflowAgentNodeBinding.tenant_id == actor.tenant_id,
|
|
WorkflowAgentNodeBinding.app_id == workflow.app_id,
|
|
WorkflowAgentNodeBinding.workflow_id == workflow.id,
|
|
WorkflowAgentNodeBinding.workflow_version == workflow.version,
|
|
WorkflowAgentNodeBinding.node_id == node_id,
|
|
WorkflowAgentNodeBinding.agent_id == selection.agent_id,
|
|
)
|
|
)
|
|
if binding is None or binding.current_snapshot_id is None:
|
|
raise BuilderExecutionPolicyError("agent_binding_owner_mismatch")
|
|
require_selected_snapshot(
|
|
selected_snapshot_id=selection.snapshot_id, effective_snapshot_id=binding.current_snapshot_id
|
|
)
|
|
# Preserve the complete validated native job (mode, contacts and
|
|
# metadata included) in its existing binding owner, in this same
|
|
# transaction. The graph's task/output projection is not a second
|
|
# persistence format for these fields.
|
|
binding.node_job_config = self._prepared[(workflow.id, node_id)][2].model_copy(deep=True)
|
|
return retirements
|