refactor: agent draft (#37356)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123 2026-06-12 11:46:21 +08:00 committed by GitHub
parent 72faca2592
commit e32a732812
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 554 additions and 75 deletions

View File

@ -18,6 +18,7 @@ from fields.agent_fields import (
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentPublishedReferenceResponse,
AgentRosterListResponse,
AgentRosterResponse,
)
@ -48,6 +49,7 @@ register_response_schema_models(
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentPublishedReferenceResponse,
AgentRosterListResponse,
AgentRosterResponse,
)

View File

@ -5,7 +5,7 @@ from dataclasses import dataclass
from sqlalchemy import select
from core.db.session_factory import session_factory
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding
class WorkflowAgentBindingError(Exception):
@ -52,11 +52,6 @@ class WorkflowAgentBindingResolver:
)
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)
@ -72,19 +67,30 @@ class WorkflowAgentBindingResolver:
f"Agent {binding.agent_id} is not available.",
)
snapshot_id = (
agent.active_config_snapshot_id
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
else binding.current_snapshot_id
)
if snapshot_id is None:
raise WorkflowAgentBindingError(
"agent_config_snapshot_not_found",
"Workflow Agent binding has no current config snapshot.",
)
snapshot = session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == binding.current_snapshot_id,
AgentConfigSnapshot.id == 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.",
f"Agent config snapshot {snapshot_id} not found.",
)
session.expunge(binding)

View File

@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from core.workflow.graph_topology import WorkflowGraphTopology
from graphon.enums import BuiltinNodeTypes
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding
from models.agent_config_entities import (
AgentFileRefConfig,
AgentHumanContactConfig,
@ -102,10 +102,6 @@ class WorkflowAgentNodeValidator:
) -> 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)
@ -120,12 +116,22 @@ class WorkflowAgentNodeValidator:
f"Workflow Agent node {binding.node_id} references an unavailable agent."
)
snapshot_id = (
agent.active_config_snapshot_id
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
else binding.current_snapshot_id
)
if snapshot_id is None:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} is missing config snapshot binding."
)
snapshot = session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == binding.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == binding.current_snapshot_id,
AgentConfigSnapshot.id == snapshot_id,
)
.limit(1)
)

View File

@ -41,10 +41,20 @@ class AgentConfigSnapshotSummaryResponse(ResponseModel):
created_at: int | None = None
class AgentPublishedReferenceResponse(ResponseModel):
app_id: str
app_name: str
app_mode: str
workflow_id: str
workflow_version: str
node_ids: list[str] = Field(default_factory=list)
class AgentRosterResponse(ResponseModel):
id: str
name: str
description: str
role: str = ""
icon_type: AgentIconType | None = None
icon: str | None = None
icon_background: str | None = None
@ -63,6 +73,9 @@ class AgentRosterResponse(ResponseModel):
archived_at: int | None = None
created_at: int | None = None
updated_at: int | None = None
published_reference_count: int = 0
published_node_reference_count: int = 0
published_references: list[AgentPublishedReferenceResponse] = Field(default_factory=list)
class AgentInviteOptionResponse(AgentRosterResponse):

View File

@ -0,0 +1,27 @@
"""add agent role
Revision ID: 0b2f2c8a9d1e
Revises: 7bad07dc267d
Create Date: 2026-06-12 11:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "0b2f2c8a9d1e"
down_revision = "7bad07dc267d"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("agents", schema=None) as batch_op:
batch_op.add_column(sa.Column("role", sa.String(length=255), nullable=False, server_default=""))
batch_op.alter_column("role", server_default=None)
def downgrade():
with op.batch_alter_table("agents", schema=None) as batch_op:
batch_op.drop_column("role")

View File

@ -136,6 +136,7 @@ class Agent(DefaultFieldsMixin, Base):
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(LongText, nullable=False, default="")
role: Mapped[str] = mapped_column(String(255), nullable=False, default="")
icon_type: Mapped[AgentIconType | None] = mapped_column(EnumText(AgentIconType, length=32), nullable=True)
icon: Mapped[str | None] = mapped_column(
String(255),

View File

@ -12148,6 +12148,10 @@ Supported icon storage formats for Agent roster entries.
| in_current_workflow_count | integer | | No |
| is_in_current_workflow | boolean | | No |
| name | string | | Yes |
| published_node_reference_count | integer | | No |
| published_reference_count | integer | | No |
| published_references | [ [AgentPublishedReferenceResponse](#agentpublishedreferenceresponse) ] | | No |
| role | string | | No |
| scope | [AgentScope](#agentscope) | | Yes |
| source | [AgentSource](#agentsource) | | Yes |
| status | [AgentStatus](#agentstatus) | | Yes |
@ -12255,6 +12259,17 @@ the current roster/workflow APIs scoped to Dify Agent.
| state | string | | No |
| status | string | | No |
#### AgentPublishedReferenceResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_id | string | | Yes |
| app_mode | string | | Yes |
| app_name | string | | Yes |
| node_ids | [ string ] | | No |
| workflow_id | string | | Yes |
| workflow_version | string | | Yes |
#### AgentReferencingWorkflowResponse
| Name | Type | Description | Required |
@ -12299,6 +12314,10 @@ the current roster/workflow APIs scoped to Dify Agent.
| icon_type | [AgentIconType](#agenticontype) | | No |
| id | string | | Yes |
| name | string | | Yes |
| published_node_reference_count | integer | | No |
| published_reference_count | integer | | No |
| published_references | [ [AgentPublishedReferenceResponse](#agentpublishedreferenceresponse) ] | | No |
| role | string | | No |
| scope | [AgentScope](#agentscope) | | Yes |
| source | [AgentSource](#agentsource) | | Yes |
| status | [AgentStatus](#agentstatus) | | Yes |
@ -16548,6 +16567,7 @@ Payload for publishing snippet workflow.
| icon_background | string | | No |
| icon_type | [AgentIconType](#agenticontype) | | No |
| name | string | | Yes |
| role | string | | No |
| version_note | string | | No |
#### RosterAgentUpdatePayload
@ -16559,6 +16579,7 @@ Payload for publishing snippet workflow.
| icon_background | string | | No |
| icon_type | [AgentIconType](#agenticontype) | | No |
| name | string | | No |
| role | string | | No |
#### RosterListQuery

View File

@ -2,7 +2,7 @@ import logging
import uuid
from typing import Any
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from extensions.ext_database import db
@ -74,10 +74,15 @@ class AgentComposerService:
return cls._empty_workflow_state(app_id=app_id, workflow_id=workflow.id, node_id=node_id)
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id)
version_id = (
agent.active_config_snapshot_id
if agent and binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
else binding.current_snapshot_id
)
version = cls._get_version_if_present(
tenant_id=tenant_id,
agent_id=agent.id if agent else None,
version_id=binding.current_snapshot_id,
version_id=version_id,
)
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
@ -129,10 +134,15 @@ class AgentComposerService:
db.session.commit()
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id)
version_id = (
agent.active_config_snapshot_id
if agent and binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
else binding.current_snapshot_id
)
version = cls._get_version_if_present(
tenant_id=tenant_id,
agent_id=agent.id if agent else None,
version_id=binding.current_snapshot_id,
version_id=version_id,
)
state = cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload)
@ -489,11 +499,26 @@ class AgentComposerService:
@classmethod
def calculate_impact(cls, *, tenant_id: str, current_snapshot_id: str) -> dict[str, Any]:
snapshot = db.session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == tenant_id,
AgentConfigSnapshot.id == current_snapshot_id,
)
.limit(1)
)
agent_id = snapshot.agent_id if snapshot else None
predicates = [WorkflowAgentNodeBinding.current_snapshot_id == current_snapshot_id]
if agent_id:
predicates.append(
(WorkflowAgentNodeBinding.agent_id == agent_id)
& (WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT)
)
bindings = list(
db.session.scalars(
select(WorkflowAgentNodeBinding).where(
WorkflowAgentNodeBinding.tenant_id == tenant_id,
WorkflowAgentNodeBinding.current_snapshot_id == current_snapshot_id,
or_(*predicates),
)
).all()
)
@ -1003,7 +1028,7 @@ class AgentComposerService:
"id": binding.id,
"binding_type": binding.binding_type.value,
"agent_id": binding.agent_id,
"current_snapshot_id": binding.current_snapshot_id,
"current_snapshot_id": version.id if version else binding.current_snapshot_id,
"workflow_id": binding.workflow_id,
"node_id": binding.node_id,
},
@ -1022,10 +1047,8 @@ class AgentComposerService:
# this is the same list (so callers don't need to special-case).
"effective_declared_outputs": cls._serialize_effective_outputs(cls._declared_outputs_from_binding(binding)),
"save_options": save_options,
"impact_summary": cls.calculate_impact(
tenant_id=binding.tenant_id, current_snapshot_id=binding.current_snapshot_id
)
if binding.current_snapshot_id
"impact_summary": cls.calculate_impact(tenant_id=binding.tenant_id, current_snapshot_id=version.id)
if version
else None,
}

View File

@ -18,6 +18,7 @@ from models.agent import (
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import AgentSoulConfig
from models.enums import AppStatus
from models.model import App
from models.workflow import Workflow
from services.agent.composer_validator import ComposerConfigValidator
@ -37,6 +38,7 @@ class AgentReferencingWorkflow(TypedDict):
app_name: str
app_mode: str
workflow_id: str
workflow_version: str
node_ids: list[str]
@ -45,11 +47,17 @@ class AgentRosterService:
self._session = session
@staticmethod
def serialize_agent(agent: Agent, active_version: AgentConfigSnapshot | None = None) -> dict[str, Any]:
def serialize_agent(
agent: Agent,
active_version: AgentConfigSnapshot | None = None,
published_references: list[AgentReferencingWorkflow] | None = None,
) -> dict[str, Any]:
published_references = published_references or []
return {
"id": agent.id,
"name": agent.name,
"description": agent.description,
"role": agent.role or "",
"icon_type": agent.icon_type.value if agent.icon_type else None,
"icon": agent.icon,
"icon_background": agent.icon_background,
@ -68,6 +76,9 @@ class AgentRosterService:
"archived_at": to_timestamp(agent.archived_at),
"created_at": to_timestamp(agent.created_at),
"updated_at": to_timestamp(agent.updated_at),
"published_reference_count": len(published_references),
"published_node_reference_count": sum(len(item["node_ids"]) for item in published_references),
"published_references": published_references,
}
@staticmethod
@ -104,13 +115,23 @@ class AgentRosterService:
versions_by_id = self._load_versions_by_id(
[agent.active_config_snapshot_id for agent in agents if agent.active_config_snapshot_id]
)
published_references_by_agent_id = self._load_published_references_by_agent_id(
tenant_id=tenant_id,
agent_ids=[agent.id for agent in agents],
)
data = []
for agent in agents:
active_version = (
versions_by_id.get(agent.active_config_snapshot_id) if agent.active_config_snapshot_id else None
)
data.append(self.serialize_agent(agent, active_version))
data.append(
self.serialize_agent(
agent,
active_version,
published_references_by_agent_id.get(agent.id, []),
)
)
return {
"data": data,
@ -170,6 +191,7 @@ class AgentRosterService:
tenant_id=tenant_id,
name=payload.name,
description=payload.description,
role=payload.role,
icon_type=payload.icon_type,
icon=payload.icon,
icon_background=payload.icon_background,
@ -241,6 +263,7 @@ class AgentRosterService:
tenant_id=tenant_id,
name=name,
description=description,
role="",
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
@ -306,48 +329,18 @@ class AgentRosterService:
if agent is None:
return []
bindings = self._session.scalars(
select(WorkflowAgentNodeBinding).where(
WorkflowAgentNodeBinding.tenant_id == tenant_id,
WorkflowAgentNodeBinding.agent_id == agent.id,
WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT,
)
).all()
if not bindings:
return []
# Collapse the per-version / per-node rows into one entry per workflow app.
node_ids_by_workflow: dict[tuple[str, str], set[str]] = {}
for binding in bindings:
node_ids_by_workflow.setdefault((binding.app_id, binding.workflow_id), set()).add(binding.node_id)
referenced_app_ids = {workflow_app_id for workflow_app_id, _ in node_ids_by_workflow}
apps = {app.id: app for app in self._session.scalars(select(App).where(App.id.in_(referenced_app_ids))).all()}
result: list[AgentReferencingWorkflow] = []
for (workflow_app_id, workflow_id), node_ids in node_ids_by_workflow.items():
app = apps.get(workflow_app_id)
if app is None:
# Orphaned binding (workflow app deleted): skip rather than 500.
continue
result.append(
AgentReferencingWorkflow(
app_id=workflow_app_id,
app_name=app.name,
app_mode=str(app.mode),
workflow_id=workflow_id,
node_ids=sorted(node_ids),
)
)
result.sort(key=lambda item: item["app_name"].lower())
return result
return self._load_published_references_by_agent_id(tenant_id=tenant_id, agent_ids=[agent.id]).get(agent.id, [])
def get_roster_agent_detail(self, *, tenant_id: str, agent_id: str) -> dict[str, Any]:
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
active_version = self._get_version(
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
)
return self.serialize_agent(agent, active_version)
published_references_by_agent_id = self._load_published_references_by_agent_id(
tenant_id=tenant_id,
agent_ids=[agent.id],
)
return self.serialize_agent(agent, active_version, published_references_by_agent_id.get(agent.id, []))
def update_roster_agent(
self, *, tenant_id: str, agent_id: str, account_id: str, payload: RosterAgentUpdatePayload
@ -450,6 +443,68 @@ class AgentRosterService:
raise AgentVersionNotFoundError()
return version
def _load_published_references_by_agent_id(
self, *, tenant_id: str, agent_ids: list[str]
) -> dict[str, list[AgentReferencingWorkflow]]:
if not agent_ids:
return {}
bindings = list(
self._session.scalars(
select(WorkflowAgentNodeBinding).where(
WorkflowAgentNodeBinding.tenant_id == tenant_id,
WorkflowAgentNodeBinding.agent_id.in_(agent_ids),
WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT,
WorkflowAgentNodeBinding.workflow_version != Workflow.VERSION_DRAFT,
)
).all()
)
if not bindings:
return {}
app_ids = {binding.app_id for binding in bindings}
apps = {
app.id: app
for app in self._session.scalars(
select(App).where(
App.tenant_id == tenant_id,
App.id.in_(app_ids),
App.status == AppStatus.NORMAL,
)
).all()
}
grouped: dict[str, dict[tuple[str, str], AgentReferencingWorkflow]] = {}
for binding in bindings:
if not binding.agent_id:
continue
app = apps.get(binding.app_id)
if app is None or app.workflow_id != binding.workflow_id:
continue
by_workflow = grouped.setdefault(binding.agent_id, {})
key = (binding.app_id, binding.workflow_id)
item = by_workflow.setdefault(
key,
AgentReferencingWorkflow(
app_id=binding.app_id,
app_name=app.name,
app_mode=str(app.mode),
workflow_id=binding.workflow_id,
workflow_version=binding.workflow_version,
node_ids=[],
),
)
item["node_ids"].append(binding.node_id)
result: dict[str, list[AgentReferencingWorkflow]] = {}
for agent_id, by_workflow in grouped.items():
references = list(by_workflow.values())
for reference in references:
reference["node_ids"] = sorted(set(reference["node_ids"]))
references.sort(key=lambda item: (item["app_name"].lower(), item["workflow_id"]))
result[agent_id] = references
return result
def _load_versions_by_id(self, version_ids: list[str]) -> dict[str, AgentConfigSnapshot]:
if not version_ids:
return {}

View File

@ -1,10 +1,13 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator
from models.agent import WorkflowAgentNodeBinding
from models.agent import Agent, AgentScope, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding
from models.agent_config_entities import WorkflowNodeJobConfig
from models.workflow import Workflow
@ -12,6 +15,9 @@ from models.workflow import Workflow
class WorkflowAgentPublishService:
"""Validate and freeze Workflow Agent v2 bindings during workflow publish."""
_DRAFT_WORKFLOW_VERSION = Workflow.VERSION_DRAFT
_AGENT_BINDING_KEY = "agent_binding"
@classmethod
def validate_agent_nodes_for_publish(cls, *, session: Session, draft_workflow: Workflow) -> None:
WorkflowAgentNodeValidator.validate_published_workflow(session=session, workflow=draft_workflow)
@ -20,6 +26,100 @@ class WorkflowAgentPublishService:
def validate_agent_nodes_for_draft_sync(cls, *, session: Session, draft_workflow: Workflow) -> None:
WorkflowAgentNodeValidator.validate_draft_workflow(session=session, workflow=draft_workflow)
@classmethod
def sync_roster_agent_bindings_for_draft(
cls,
*,
session: Session,
draft_workflow: Workflow,
account_id: str,
) -> None:
agent_nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(draft_workflow.graph_dict))
existing_bindings = list(
session.scalars(
select(WorkflowAgentNodeBinding).where(
WorkflowAgentNodeBinding.tenant_id == draft_workflow.tenant_id,
WorkflowAgentNodeBinding.app_id == draft_workflow.app_id,
WorkflowAgentNodeBinding.workflow_id == draft_workflow.id,
WorkflowAgentNodeBinding.workflow_version == cls._DRAFT_WORKFLOW_VERSION,
)
).all()
)
existing_by_node_id = {binding.node_id: binding for binding in existing_bindings}
for binding in existing_bindings:
if binding.node_id not in agent_nodes:
session.delete(binding)
for node_id, node_data in agent_nodes.items():
binding_payload = node_data.get(cls._AGENT_BINDING_KEY)
if binding_payload is None:
continue
if not isinstance(binding_payload, Mapping):
raise ValueError(f"Workflow Agent node {node_id} has invalid agent_binding.")
cls._sync_roster_agent_binding_for_node(
session=session,
draft_workflow=draft_workflow,
node_id=node_id,
node_binding=binding_payload,
existing_binding=existing_by_node_id.get(node_id),
account_id=account_id,
)
session.flush()
@classmethod
def _sync_roster_agent_binding_for_node(
cls,
*,
session: Session,
draft_workflow: Workflow,
node_id: str,
node_binding: Mapping[str, Any],
existing_binding: WorkflowAgentNodeBinding | None,
account_id: str,
) -> None:
binding_type = node_binding.get("binding_type")
if binding_type != WorkflowAgentBindingType.ROSTER_AGENT.value:
raise ValueError(f"Workflow Agent node {node_id} only supports roster_agent graph binding.")
agent_id = node_binding.get("agent_id")
if not isinstance(agent_id, str) or not agent_id:
raise ValueError(f"Workflow Agent node {node_id} roster_agent binding requires agent_id.")
agent = session.scalar(
select(Agent)
.where(
Agent.tenant_id == draft_workflow.tenant_id,
Agent.id == agent_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
)
.limit(1)
)
if agent is None:
raise ValueError(f"Workflow Agent node {node_id} references an unavailable roster agent.")
if not agent.active_config_snapshot_id:
raise ValueError(f"Workflow Agent node {node_id} roster agent has no active config snapshot.")
binding = existing_binding
if binding is None:
binding = WorkflowAgentNodeBinding(
tenant_id=draft_workflow.tenant_id,
app_id=draft_workflow.app_id,
workflow_id=draft_workflow.id,
workflow_version=cls._DRAFT_WORKFLOW_VERSION,
node_id=node_id,
node_job_config=WorkflowNodeJobConfig(),
created_by=account_id,
)
session.add(binding)
elif not binding.node_job_config:
binding.node_job_config = WorkflowNodeJobConfig()
binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT
binding.agent_id = agent.id
binding.current_snapshot_id = agent.active_config_snapshot_id
binding.updated_by = account_id
@classmethod
def copy_agent_node_bindings_to_published(
cls,
@ -43,8 +143,26 @@ class WorkflowAgentPublishService:
WorkflowAgentNodeBinding.node_id.in_(node_ids),
)
).all()
if not bindings:
return
agents_by_id = {
agent.id: agent
for agent in session.scalars(
select(Agent).where(
Agent.tenant_id == draft_workflow.tenant_id,
Agent.id.in_({binding.agent_id for binding in bindings if binding.agent_id}),
)
).all()
}
for binding in bindings:
agent = agents_by_id.get(binding.agent_id) if binding.agent_id else None
current_snapshot_id = (
agent.active_config_snapshot_id
if agent is not None and binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
else binding.current_snapshot_id
)
copied = WorkflowAgentNodeBinding(
tenant_id=binding.tenant_id,
app_id=binding.app_id,
@ -53,7 +171,7 @@ class WorkflowAgentPublishService:
node_id=binding.node_id,
binding_type=binding.binding_type,
agent_id=binding.agent_id,
current_snapshot_id=binding.current_snapshot_id,
current_snapshot_id=current_snapshot_id,
node_job_config=WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict),
created_by=binding.created_by,
updated_by=binding.updated_by,

View File

@ -61,6 +61,7 @@ class ComposerSavePayload(BaseModel):
class RosterAgentCreatePayload(BaseModel):
name: str = Field(min_length=1, max_length=255)
description: str = ""
role: str = Field(default="", max_length=255)
icon_type: AgentIconType | None = None
icon: str | None = Field(default=None, max_length=255)
icon_background: str | None = Field(default=None, max_length=255)
@ -71,6 +72,7 @@ class RosterAgentCreatePayload(BaseModel):
class RosterAgentUpdatePayload(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
role: str | None = Field(default=None, max_length=255)
icon_type: AgentIconType | None = None
icon: str | None = Field(default=None, max_length=255)
icon_background: str | None = Field(default=None, max_length=255)

View File

@ -322,6 +322,12 @@ class WorkflowService:
from services.agent.workflow_publish_service import WorkflowAgentPublishService
db.session.flush()
WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft(
session=cast(Session, db.session),
draft_workflow=workflow,
account_id=account.id,
)
WorkflowAgentPublishService.validate_agent_nodes_for_draft_sync(
session=cast(Session, db.session),
draft_workflow=workflow,

View File

@ -4,7 +4,7 @@ from core.workflow.nodes.agent_v2.binding_resolver import (
WorkflowAgentBindingError,
WorkflowAgentBindingResolver,
)
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding
from models.agent_config_entities import AgentSoulConfig, AgentSoulModelConfig, WorkflowNodeJobConfig
@ -85,6 +85,25 @@ def test_binding_resolver_returns_detached_binding_bundle(monkeypatch: pytest.Mo
assert fake_session.expunge_calls == [bundle.binding, bundle.agent, bundle.snapshot]
def test_binding_resolver_uses_active_snapshot_for_roster_agent(monkeypatch: pytest.MonkeyPatch):
binding = _binding()
binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT
binding.current_snapshot_id = "old-snapshot"
agent = _agent()
agent.active_config_snapshot_id = "active-snapshot"
snapshot = _snapshot()
snapshot.id = "active-snapshot"
fake_session = FakeSession([binding, agent, snapshot])
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.binding_resolver.session_factory.create_session",
lambda: fake_session,
)
bundle = WorkflowAgentBindingResolver().resolve(**_resolve())
assert bundle.snapshot.id == "active-snapshot"
def test_binding_resolver_raises_when_binding_missing(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.binding_resolver.session_factory.create_session",

View File

@ -8,7 +8,7 @@ from core.workflow.nodes.agent_v2.validators import (
WorkflowAgentNodeValidationError,
WorkflowAgentNodeValidator,
)
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding
from models.agent_config_entities import AgentSoulConfig, AgentSoulModelConfig, WorkflowNodeJobConfig
from models.workflow import Workflow
@ -111,6 +111,24 @@ def test_publish_validation_accepts_upstream_previous_output_ref():
)
def test_publish_validation_uses_active_snapshot_for_roster_agent():
node_job = WorkflowNodeJobConfig()
binding = _binding(node_job)
binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT
binding.current_snapshot_id = "old-snapshot"
agent = _agent()
agent.active_config_snapshot_id = "active-snapshot"
snapshot = _snapshot()
snapshot.id = "active-snapshot"
session = Mock()
session.scalar.side_effect = [binding, agent, snapshot]
WorkflowAgentNodeValidator.validate_published_workflow(
session=session,
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
)
def test_publish_validation_rejects_non_upstream_previous_output_ref():
node_job = WorkflowNodeJobConfig.model_validate(
{"previous_node_output_refs": [{"node_id": "later-node", "output": "text"}]}

View File

@ -14,11 +14,14 @@ from models.agent import (
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import WorkflowNodeJobConfig
from models.workflow import Workflow
from services.agent import composer_service, roster_service
from services.agent.composer_service import AgentComposerService
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.errors import InvalidComposerConfigError
from services.agent.roster_service import AgentRosterService
from services.agent.workflow_publish_service import WorkflowAgentPublishService
from services.entities.agent_entities import AgentSoulConfig, ComposerSavePayload, ComposerSaveStrategy, ComposerVariant
@ -35,6 +38,7 @@ class FakeSession:
self._scalars = list(scalars or [])
self._scalar = list(scalar or [])
self.added = []
self.deleted = []
self.commits = 0
self.flushes = 0
self.rollbacks = 0
@ -52,6 +56,9 @@ class FakeSession:
def add(self, value):
self.added.append(value)
def delete(self, value):
self.deleted.append(value)
def flush(self):
self.flushes += 1
for index, value in enumerate(self.added, start=1):
@ -84,10 +91,18 @@ def test_load_workflow_composer_returns_empty_state(monkeypatch):
def test_load_workflow_composer_serializes_existing_binding(monkeypatch):
binding = SimpleNamespace(agent_id="agent-1", current_snapshot_id="version-1")
binding = SimpleNamespace(
agent_id="agent-1",
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
current_snapshot_id="version-1",
)
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1"))
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding)
monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: SimpleNamespace(id="agent-1"))
monkeypatch.setattr(
AgentComposerService,
"_get_agent_if_present",
lambda **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1"),
)
monkeypatch.setattr(
AgentComposerService,
"_get_version_if_present",
@ -116,14 +131,22 @@ def test_load_workflow_composer_serializes_existing_binding(monkeypatch):
)
def test_save_workflow_composer_dispatches_save_strategy(monkeypatch, strategy, helper_name):
fake_session = FakeSession()
binding = SimpleNamespace(agent_id="agent-1", current_snapshot_id="version-1")
binding = SimpleNamespace(
agent_id="agent-1",
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
current_snapshot_id="version-1",
)
calls = []
monkeypatch.setattr(composer_service.db, "session", fake_session)
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1"))
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: None)
monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: SimpleNamespace(id="agent-1"))
monkeypatch.setattr(
AgentComposerService,
"_get_agent_if_present",
lambda **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1"),
)
monkeypatch.setattr(
AgentComposerService,
"_get_version_if_present",
@ -523,6 +546,7 @@ def test_roster_list_and_invite_options(monkeypatch):
tenant_id="tenant-1",
name="Analyst",
description="",
role="researcher",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
@ -539,11 +563,13 @@ def test_roster_list_and_invite_options(monkeypatch):
)
service = AgentRosterService(fake_session)
monkeypatch.setattr(service, "_load_versions_by_id", lambda version_ids: {"version-1": version})
monkeypatch.setattr(service, "_load_published_references_by_agent_id", lambda **kwargs: {})
listed = service.list_roster_agents(tenant_id="tenant-1", page=1, limit=20)
invited = service.list_invite_options(tenant_id="tenant-1", page=1, limit=20, app_id="app-1")
assert listed["data"][0]["active_config_snapshot"]["id"] == "version-1"
assert listed["data"][0]["role"] == "researcher"
assert listed["data"][0]["created_at"] == int(created_at.timestamp())
assert listed["data"][0]["updated_at"] == int(updated_at.timestamp())
assert listed["data"][0]["active_config_snapshot"]["created_at"] == int(version_created_at.timestamp())
@ -886,13 +912,19 @@ class TestListWorkflowsReferencingAppAgent:
def test_groups_bindings_by_workflow_app_and_sorts_by_name(self):
agent = SimpleNamespace(id="agent-1")
bindings = [
SimpleNamespace(app_id="wf-app-1", workflow_id="wf-1", node_id="node-b"),
SimpleNamespace(app_id="wf-app-1", workflow_id="wf-1", node_id="node-a"),
SimpleNamespace(app_id="wf-app-2", workflow_id="wf-2", node_id="node-a"),
SimpleNamespace(
agent_id="agent-1", app_id="wf-app-1", workflow_id="wf-1", workflow_version="v1", node_id="node-b"
),
SimpleNamespace(
agent_id="agent-1", app_id="wf-app-1", workflow_id="wf-1", workflow_version="v1", node_id="node-a"
),
SimpleNamespace(
agent_id="agent-1", app_id="wf-app-2", workflow_id="wf-2", workflow_version="v2", node_id="node-a"
),
]
apps = [
SimpleNamespace(id="wf-app-1", name="Beta Flow", mode="workflow"),
SimpleNamespace(id="wf-app-2", name="Alpha Flow", mode="advanced-chat"),
SimpleNamespace(id="wf-app-1", name="Beta Flow", mode="workflow", workflow_id="wf-1"),
SimpleNamespace(id="wf-app-2", name="Alpha Flow", mode="advanced-chat", workflow_id="wf-2"),
]
# scalar -> backing agent; scalars -> bindings, then resolved apps.
session = FakeSession(scalar=[agent], scalars=[bindings, apps])
@ -904,6 +936,7 @@ class TestListWorkflowsReferencingAppAgent:
beta = next(r for r in result if r["app_id"] == "wf-app-1")
assert beta["node_ids"] == ["node-a", "node-b"] # deduped + sorted
assert beta["workflow_id"] == "wf-1"
assert beta["workflow_version"] == "v1"
def test_returns_empty_when_no_backing_agent(self):
session = FakeSession() # scalar() -> None
@ -920,12 +953,100 @@ class TestListWorkflowsReferencingAppAgent:
def test_skips_orphaned_binding_whose_app_is_gone(self):
agent = SimpleNamespace(id="agent-1")
bindings = [SimpleNamespace(app_id="wf-app-gone", workflow_id="wf-9", node_id="node-a")]
bindings = [
SimpleNamespace(
agent_id="agent-1", app_id="wf-app-gone", workflow_id="wf-9", workflow_version="v9", node_id="node-a"
)
]
session = FakeSession(scalar=[agent], scalars=[bindings, []]) # no apps resolved
service = AgentRosterService(session)
assert service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-1") == []
def test_skips_historical_published_workflow_versions(self):
agent = SimpleNamespace(id="agent-1")
bindings = [
SimpleNamespace(
agent_id="agent-1", app_id="wf-app-1", workflow_id="old-wf", workflow_version="old", node_id="old"
),
SimpleNamespace(
agent_id="agent-1", app_id="wf-app-1", workflow_id="current-wf", workflow_version="v2", node_id="new"
),
]
apps = [SimpleNamespace(id="wf-app-1", name="Flow", mode="workflow", workflow_id="current-wf")]
session = FakeSession(scalar=[agent], scalars=[bindings, apps])
service = AgentRosterService(session)
result = service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-1")
assert len(result) == 1
assert result[0]["workflow_id"] == "current-wf"
assert result[0]["node_ids"] == ["new"]
class TestWorkflowAgentDraftBindingSync:
def test_creates_roster_binding_from_agent_node_graph(self):
workflow = Workflow(
id="workflow-1",
tenant_id="tenant-1",
app_id="app-1",
version=Workflow.VERSION_DRAFT,
graph='{"nodes":[{"id":"agent-node","data":{"type":"agent","version":"2","agent_binding":{"binding_type":"roster_agent","agent_id":"agent-1"}}}]}',
)
agent = Agent(
id="agent-1",
tenant_id="tenant-1",
name="Agent",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
active_config_snapshot_id="snapshot-2",
)
session = FakeSession(scalar=[agent], scalars=[[]])
WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft(
session=session,
draft_workflow=workflow,
account_id="account-1",
)
binding = next(item for item in session.added if isinstance(item, WorkflowAgentNodeBinding))
assert binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
assert binding.agent_id == "agent-1"
assert binding.current_snapshot_id == "snapshot-2"
assert binding.node_job_config_dict == WorkflowNodeJobConfig().model_dump(mode="json")
def test_deletes_draft_binding_when_agent_node_removed(self):
workflow = Workflow(
id="workflow-1",
tenant_id="tenant-1",
app_id="app-1",
version=Workflow.VERSION_DRAFT,
graph='{"nodes":[]}',
)
stale_binding = WorkflowAgentNodeBinding(
id="binding-1",
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_version=Workflow.VERSION_DRAFT,
node_id="removed-node",
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
agent_id="agent-1",
current_snapshot_id="snapshot-1",
node_job_config=WorkflowNodeJobConfig(),
)
session = FakeSession(scalars=[[stale_binding]])
WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft(
session=session,
draft_workflow=workflow,
account_id="account-1",
)
assert session.deleted == [stale_binding]
def test_dataset_rows_filters_malformed_ids(monkeypatch):
"""Mention ids are user-editable text: a non-UUID id must read as missing

View File

@ -19,6 +19,7 @@ export type RosterAgentCreatePayload = {
icon_background?: string | null
icon_type?: AgentIconType
name: string
role?: string
version_note?: string | null
}
@ -37,6 +38,10 @@ export type AgentRosterResponse = {
icon_type?: AgentIconType
id: string
name: string
published_node_reference_count?: number
published_reference_count?: number
published_references?: Array<AgentPublishedReferenceResponse>
role?: string
scope: AgentScope
source: AgentSource
status: AgentStatus
@ -60,6 +65,7 @@ export type RosterAgentUpdatePayload = {
icon_background?: string | null
icon_type?: AgentIconType
name?: string | null
role?: string | null
}
export type AgentConfigSnapshotListResponse = {
@ -108,6 +114,15 @@ export type AgentConfigSnapshotSummaryResponse = {
export type AgentKind = 'dify_agent'
export type AgentPublishedReferenceResponse = {
app_id: string
app_mode: string
app_name: string
node_ids?: Array<string>
workflow_id: string
workflow_version: string
}
export type AgentScope = 'roster' | 'workflow_only'
export type AgentSource = 'agent_app' | 'imported' | 'system' | 'workflow'
@ -132,6 +147,10 @@ export type AgentInviteOptionResponse = {
in_current_workflow_count?: number
is_in_current_workflow?: boolean
name: string
published_node_reference_count?: number
published_reference_count?: number
published_references?: Array<AgentPublishedReferenceResponse>
role?: string
scope: AgentScope
source: AgentSource
status: AgentStatus

View File

@ -18,6 +18,7 @@ export const zRosterAgentUpdatePayload = z.object({
icon_background: z.string().max(255).nullish(),
icon_type: zAgentIconType.optional(),
name: z.string().min(1).max(255).nullish(),
role: z.string().max(255).nullish(),
})
/**
@ -50,6 +51,18 @@ export const zAgentConfigSnapshotListResponse = z.object({
*/
export const zAgentKind = z.enum(['dify_agent'])
/**
* AgentPublishedReferenceResponse
*/
export const zAgentPublishedReferenceResponse = z.object({
app_id: z.string(),
app_mode: z.string(),
app_name: z.string(),
node_ids: z.array(z.string()).optional(),
workflow_id: z.string(),
workflow_version: z.string(),
})
/**
* AgentScope
*
@ -89,6 +102,10 @@ export const zAgentRosterResponse = z.object({
icon_type: zAgentIconType.optional(),
id: z.string(),
name: z.string(),
published_node_reference_count: z.int().optional().default(0),
published_reference_count: z.int().optional().default(0),
published_references: z.array(zAgentPublishedReferenceResponse).optional(),
role: z.string().optional().default(''),
scope: zAgentScope,
source: zAgentSource,
status: zAgentStatus,
@ -130,6 +147,10 @@ export const zAgentInviteOptionResponse = z.object({
in_current_workflow_count: z.int().optional().default(0),
is_in_current_workflow: z.boolean().optional().default(false),
name: z.string(),
published_node_reference_count: z.int().optional().default(0),
published_reference_count: z.int().optional().default(0),
published_references: z.array(zAgentPublishedReferenceResponse).optional(),
role: z.string().optional().default(''),
scope: zAgentScope,
source: zAgentSource,
status: zAgentStatus,
@ -632,6 +653,7 @@ export const zRosterAgentCreatePayload = z.object({
icon_background: z.string().max(255).nullish(),
icon_type: zAgentIconType.optional(),
name: z.string().min(1).max(255),
role: z.string().max(255).optional().default(''),
version_note: z.string().nullish(),
})