feat: add new agent (#36284)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123
2026-05-19 10:43:23 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent d1417bbe4b
commit d9e90d0fa0
54 changed files with 7377 additions and 242 deletions
+24
View File
@@ -8,6 +8,19 @@ from .account import (
TenantAccountRole,
TenantStatus,
)
from .agent import (
Agent,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
AgentIconType,
AgentKind,
AgentScope,
AgentSource,
AgentStatus,
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint
from .comment import (
WorkflowComment,
@@ -125,6 +138,15 @@ __all__ = [
"AccountIntegrate",
"AccountStatus",
"AccountTrialAppRecord",
"Agent",
"AgentConfigRevision",
"AgentConfigRevisionOperation",
"AgentConfigSnapshot",
"AgentIconType",
"AgentKind",
"AgentScope",
"AgentSource",
"AgentStatus",
"ApiRequest",
"ApiToken",
"ApiToolProvider",
@@ -210,6 +232,8 @@ __all__ = [
"UploadFile",
"Whitelist",
"Workflow",
"WorkflowAgentBindingType",
"WorkflowAgentNodeBinding",
"WorkflowAppLog",
"WorkflowAppLogCreatedFrom",
"WorkflowArchiveLog",
+263
View File
@@ -0,0 +1,263 @@
import json
from datetime import datetime
from enum import StrEnum
from typing import Any
import sqlalchemy as sa
from sqlalchemy import DateTime, Index, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from libs.datetime_utils import naive_utc_now
from libs.uuid_utils import uuidv7
from .agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
from .base import Base, DefaultFieldsMixin
from .types import EnumText, JSONModelColumn, LongText, StringUUID
class AgentKind(StrEnum):
"""Agent implementation family.
This leaves room for future non-Dify agent implementations while keeping
the current roster/workflow APIs scoped to Dify Agent.
"""
# Native Agent backed by the Dify Agent runtime/protocol.
DIFY_AGENT = "dify_agent"
class AgentScope(StrEnum):
"""Visibility and lifecycle scope of an Agent record."""
# Workspace-visible Agent that can be reused from Agent Roster.
ROSTER = "roster"
# Temporary workflow-local Agent created inside one draft workflow node.
WORKFLOW_ONLY = "workflow_only"
class AgentSource(StrEnum):
"""Origin that created or imported the Agent."""
# Created from an Agent App composer.
AGENT_APP = "agent_app"
# Created from a Workflow Agent Composer flow.
WORKFLOW = "workflow"
# Imported from an external artifact or future CLI/export flow.
IMPORTED = "imported"
# Created by system bootstrap or managed templates.
SYSTEM = "system"
class AgentIconType(StrEnum):
"""Supported icon storage formats for Agent roster entries."""
# ``icon`` stores an uploaded image reference.
IMAGE = "image"
# ``icon`` stores an emoji character.
EMOJI = "emoji"
# ``icon`` stores an external image URL.
LINK = "link"
class AgentStatus(StrEnum):
"""Soft lifecycle state for Agent records."""
# Available for roster lookup, composer use, and workflow binding.
ACTIVE = "active"
# Hidden from active roster queries while preserving historical bindings.
ARCHIVED = "archived"
class AgentConfigRevisionOperation(StrEnum):
"""Audit operation recorded for Agent Soul version/revision changes."""
# Initial version creation for a new Agent.
CREATE_VERSION = "create_version"
# Saves over the user-facing current version by creating a replacement snapshot.
SAVE_CURRENT_VERSION = "save_current_version"
# Creates a new semantic version for the same Agent.
SAVE_NEW_VERSION = "save_new_version"
# Saves composer content into a brand-new roster Agent.
SAVE_NEW_AGENT = "save_new_agent"
# Promotes a workflow-only Agent into the reusable Agent Roster.
SAVE_TO_ROSTER = "save_to_roster"
class WorkflowAgentBindingType(StrEnum):
"""How a workflow node is bound to an Agent."""
# Node uses a reusable Agent from the workspace roster.
ROSTER_AGENT = "roster_agent"
# Node owns a workflow-only Agent that is not visible in the roster.
INLINE_AGENT = "inline_agent"
class Agent(DefaultFieldsMixin, Base):
"""Workspace-scoped Agent identity used by Agent Roster and workflow-only agents."""
__tablename__ = "agents"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="agent_pkey"),
UniqueConstraint("tenant_id", "roster_unique_name"),
Index("agent_tenant_updated_at_idx", "tenant_id", "updated_at"),
Index("agent_tenant_scope_idx", "tenant_id", "scope"),
Index("agent_tenant_workflow_id_idx", "tenant_id", "workflow_id"),
Index("agent_tenant_app_id_idx", "tenant_id", "app_id"),
Index("agent_active_config_snapshot_id_idx", "active_config_snapshot_id"),
)
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="")
icon_type: Mapped[AgentIconType | None] = mapped_column(EnumText(AgentIconType, length=32), nullable=True)
icon: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
comment="Icon payload interpreted by icon_type: emoji character, image file id, or external URL.",
)
icon_background: Mapped[str | None] = mapped_column(String(255), nullable=True)
agent_kind: Mapped[AgentKind] = mapped_column(
EnumText(AgentKind, length=32), nullable=False, default=AgentKind.DIFY_AGENT
)
scope: Mapped[AgentScope] = mapped_column(EnumText(AgentScope, length=32), nullable=False)
source: Mapped[AgentSource] = mapped_column(EnumText(AgentSource, length=32), nullable=False)
app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
workflow_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
active_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
status: Mapped[AgentStatus] = mapped_column(
EnumText(AgentStatus, length=32), nullable=False, default=AgentStatus.ACTIVE
)
roster_unique_name: Mapped[str | None] = mapped_column(
String(255),
sa.Computed("CASE WHEN scope = 'roster' AND status = 'active' THEN name ELSE NULL END"),
nullable=True,
)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
archived_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
archived_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class AgentConfigSnapshot(DefaultFieldsMixin, Base):
"""Immutable Agent Soul snapshot.
``config_snapshot`` stores ``AgentSoulConfig`` as JSON-backed ``LongText``.
It may contain credential or secret references, but must never contain
plaintext secrets.
"""
__tablename__ = "agent_config_snapshots"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="agent_config_snapshot_pkey"),
UniqueConstraint("agent_id", "version", name="agent_config_snapshot_agent_version_unique"),
Index("agent_config_snapshot_tenant_agent_created_at_idx", "tenant_id", "agent_id", "created_at"),
Index("agent_config_snapshot_tenant_created_at_idx", "tenant_id", "created_at"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
version: Mapped[int] = mapped_column(sa.Integer, nullable=False)
config_snapshot: Mapped[Any] = mapped_column(JSONModelColumn(AgentSoulConfig), nullable=False)
summary: Mapped[str | None] = mapped_column(LongText, nullable=True)
version_note: Mapped[str | None] = mapped_column(LongText, nullable=True)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
@property
def config_snapshot_dict(self) -> dict[str, Any]:
if not self.config_snapshot:
return {}
if hasattr(self.config_snapshot, "model_dump"):
return self.config_snapshot.model_dump(mode="json")
if isinstance(self.config_snapshot, str):
return json.loads(self.config_snapshot)
return dict(self.config_snapshot)
class AgentConfigRevision(Base):
"""Audit edge for every Agent Soul save operation.
Revisions link immutable Agent Soul snapshots instead of duplicating the
serialized configuration JSON.
"""
__tablename__ = "agent_config_revisions"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="agent_config_revision_pkey"),
UniqueConstraint(
"agent_id",
"revision",
name="agent_config_revision_agent_revision_unique",
),
Index("agent_config_revision_tenant_agent_created_at_idx", "tenant_id", "agent_id", "created_at"),
Index(
"agent_config_revision_tenant_current_snapshot_created_at_idx",
"tenant_id",
"current_snapshot_id",
"created_at",
),
)
id: Mapped[str] = mapped_column(StringUUID, primary_key=True, default=lambda: str(uuidv7()))
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
previous_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
current_snapshot_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
revision: Mapped[int] = mapped_column(sa.Integer, nullable=False)
operation: Mapped[AgentConfigRevisionOperation] = mapped_column(
EnumText(AgentConfigRevisionOperation, length=64), nullable=False
)
summary: Mapped[str | None] = mapped_column(LongText, nullable=True)
version_note: Mapped[str | None] = mapped_column(LongText, nullable=True)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=naive_utc_now,
server_default=func.current_timestamp(),
)
class WorkflowAgentNodeBinding(DefaultFieldsMixin, Base):
"""Binding between one workflow node and one Agent config snapshot.
``node_job_config`` stores Workflow Node Job JSON only. Agent Soul belongs
to ``AgentConfigSnapshot.config_snapshot`` and must not be duplicated here.
"""
__tablename__ = "workflow_agent_node_bindings"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="workflow_agent_node_binding_pkey"),
UniqueConstraint(
"tenant_id",
"workflow_id",
"node_id",
name="workflow_agent_node_binding_node_unique",
),
Index("workflow_agent_node_binding_agent_idx", "tenant_id", "agent_id"),
Index("workflow_agent_node_binding_current_snapshot_idx", "tenant_id", "current_snapshot_id"),
Index("workflow_agent_node_binding_app_idx", "tenant_id", "app_id"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
workflow_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
node_id: Mapped[str] = mapped_column(String(255), nullable=False)
binding_type: Mapped[WorkflowAgentBindingType] = mapped_column(
EnumText(WorkflowAgentBindingType, length=32), nullable=False
)
agent_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
current_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
node_job_config: Mapped[Any] = mapped_column(JSONModelColumn(WorkflowNodeJobConfig), nullable=False)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
@property
def node_job_config_dict(self) -> dict[str, Any]:
if not self.node_job_config:
return {}
if hasattr(self.node_job_config, "model_dump"):
return self.node_job_config.model_dump(mode="json")
if isinstance(self.node_job_config, str):
return json.loads(self.node_job_config)
return dict(self.node_job_config)
+136
View File
@@ -0,0 +1,136 @@
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, model_validator
class AgentKnowledgeQueryMode(StrEnum):
USER_QUERY = "user_query"
GENERATED_QUERY = "generated_query"
class WorkflowNodeJobMode(StrEnum):
LET_AGENT_FIGURE_IT_OUT = "let_agent_figure_it_out"
TELL_AGENT_WHAT_TO_DO = "tell_agent_what_to_do"
class DeclaredOutputType(StrEnum):
STRING = "string"
NUMBER = "number"
OBJECT = "object"
ARRAY = "array"
BOOLEAN = "boolean"
FILE = "file"
class AgentSoulPromptConfig(BaseModel):
system_prompt: str = ""
class AgentSoulSkillsFilesConfig(BaseModel):
files: list[dict[str, Any]] = Field(default_factory=list)
skills: list[dict[str, Any]] = Field(default_factory=list)
class AgentSoulToolsConfig(BaseModel):
dify_tools: list[dict[str, Any]] = Field(default_factory=list)
cli_tools: list[dict[str, Any]] = Field(default_factory=list)
class AgentSoulKnowledgeConfig(BaseModel):
datasets: list[dict[str, Any]] = Field(default_factory=list)
query_mode: AgentKnowledgeQueryMode | None = None
query_config: dict[str, Any] = Field(default_factory=dict)
class AgentSoulHumanConfig(BaseModel):
contacts: list[dict[str, Any]] = Field(default_factory=list)
tools: list[dict[str, Any]] = Field(default_factory=list)
class AgentSoulEnvConfig(BaseModel):
variables: list[dict[str, Any]] = Field(default_factory=list)
secret_refs: list[dict[str, Any]] = Field(default_factory=list)
class AgentSoulSandboxConfig(BaseModel):
provider: str | None = None
config: dict[str, Any] = Field(default_factory=dict)
class AgentSoulMemoryConfig(BaseModel):
scope: str | None = None
budget: str | None = None
artifacts: list[dict[str, Any]] = Field(default_factory=list)
class AppVariableConfig(BaseModel):
name: str = Field(min_length=1, max_length=255)
type: str = Field(min_length=1, max_length=64)
required: bool = False
default: Any = None
class AgentSoulConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
schema_version: int = 1
prompt: AgentSoulPromptConfig = Field(default_factory=AgentSoulPromptConfig)
skills_files: AgentSoulSkillsFilesConfig = Field(default_factory=AgentSoulSkillsFilesConfig)
tools: AgentSoulToolsConfig = Field(default_factory=AgentSoulToolsConfig)
knowledge: AgentSoulKnowledgeConfig = Field(default_factory=AgentSoulKnowledgeConfig)
human: AgentSoulHumanConfig = Field(default_factory=AgentSoulHumanConfig)
env: AgentSoulEnvConfig = Field(default_factory=AgentSoulEnvConfig)
sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig)
memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig)
app_features: dict[str, Any] = Field(default_factory=dict)
app_variables: list[AppVariableConfig] = Field(default_factory=list)
misc_legacy: dict[str, Any] = Field(default_factory=dict)
class DeclaredOutputFileConfig(BaseModel):
extensions: list[str] = Field(default_factory=list)
mime_types: list[str] = Field(default_factory=list)
class DeclaredOutputCheckConfig(BaseModel):
type: str = Field(min_length=1, max_length=64)
prompt: str | None = None
benchmark_file_ref: dict[str, Any] | None = None
class DeclaredOutputFailureStrategy(BaseModel):
on_type_check_failed: str | None = None
on_output_check_failed: str | None = None
max_retries: int = Field(default=0, ge=0, le=10)
class DeclaredOutputConfig(BaseModel):
id: str | None = None
name: str = Field(min_length=1, max_length=255)
type: DeclaredOutputType
description: str | None = None
required: bool = True
file: DeclaredOutputFileConfig | None = None
checks: list[DeclaredOutputCheckConfig] = Field(default_factory=list)
failure_strategy: DeclaredOutputFailureStrategy | None = None
@model_validator(mode="after")
def validate_file_metadata(self) -> "DeclaredOutputConfig":
if self.type == DeclaredOutputType.FILE and self.file is None:
self.file = DeclaredOutputFileConfig()
if self.type != DeclaredOutputType.FILE and self.file is not None:
raise ValueError("file metadata is only allowed for file outputs")
return self
class WorkflowNodeJobConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
schema_version: int = 1
mode: WorkflowNodeJobMode = WorkflowNodeJobMode.TELL_AGENT_WHAT_TO_DO
workflow_prompt: str = ""
previous_node_output_refs: list[dict[str, Any]] = Field(default_factory=list)
declared_outputs: list[DeclaredOutputConfig] = Field(default_factory=list)
human_contacts: list[dict[str, Any]] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
+41
View File
@@ -1,8 +1,10 @@
import enum
import json
import uuid
from typing import Any, cast
import sqlalchemy as sa
from pydantic import BaseModel
from sqlalchemy import CHAR, TEXT, VARCHAR, LargeBinary, TypeDecorator
from sqlalchemy.dialects.mysql import LONGBLOB, LONGTEXT
from sqlalchemy.dialects.postgresql import BYTEA, JSONB, UUID
@@ -61,6 +63,45 @@ class LongText(TypeDecorator[str | None]):
return value
class JSONModelColumn[T: BaseModel](TypeDecorator[T | None]):
"""Store a Pydantic model as dialect-adjusted LongText JSON."""
impl = TEXT
cache_ok = True
_model_class: type[T]
def __init__(self, model_class: type[T]):
if not issubclass(model_class, BaseModel):
raise TypeError(f"{model_class.__module__}.{model_class.__name__} must be a Pydantic BaseModel subclass")
self._model_class = model_class
super().__init__()
def load_dialect_impl(self, dialect: Dialect) -> TypeEngine[Any]:
if dialect.name == "postgresql":
return dialect.type_descriptor(TEXT())
elif dialect.name == "mysql":
return dialect.type_descriptor(LONGTEXT())
else:
return dialect.type_descriptor(TEXT())
def process_bind_param(self, value: T | dict[str, Any] | str | None, dialect: Dialect) -> str | None:
if value is None:
return None
if isinstance(value, self._model_class):
model = value
elif isinstance(value, str):
model = self._model_class.model_validate_json(value)
else:
model = self._model_class.model_validate(value)
return json.dumps(model.model_dump(mode="json"), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def process_result_value(self, value: str | None, dialect: Dialect) -> T | None:
if value is None or value == "":
return None
return self._model_class.model_validate_json(value)
class BinaryData(TypeDecorator[bytes | None]):
impl = LargeBinary
cache_ok = True