From 2382a496166e44037cc387ca3687d833cc0b2cfe Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Wed, 24 Jun 2026 21:21:00 +0800 Subject: [PATCH] feat(agent-v2): add agent draft build lifecycle (#37887) --- api/controllers/console/agent/roster.py | 146 +++++- api/controllers/console/app/completion.py | 4 + api/core/app/apps/agent_app/app_generator.py | 138 +++++- api/fields/agent_fields.py | 18 +- ...15-e4f5a6b7c8d9_add_agent_config_drafts.py | 135 ++++++ api/models/__init__.py | 4 + api/models/agent.py | 57 ++- api/services/agent/composer_service.py | 420 +++++++++++++++--- api/services/agent/roster_service.py | 67 ++- .../console/agent/test_agent_controllers.py | 131 ++++++ .../app/apps/agent_app/test_app_generator.py | 9 +- api/tests/unit_tests/models/test_agent.py | 23 + .../services/agent/test_agent_services.py | 168 ++++++- 13 files changed, 1183 insertions(+), 137 deletions(-) create mode 100644 api/migrations/versions/2026_06_24_2015-e4f5a6b7c8d9_add_agent_config_drafts.py diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index ac3f7ef4824..3a13842a5a5 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -56,6 +56,7 @@ from libs.login import login_required from models import Account from models.enums import ApiTokenType from models.model import ApiToken, App, IconType +from services.agent.composer_service import AgentComposerService from services.agent.errors import AgentNotFoundError from services.agent.observability_service import ( AgentLogQueryParams, @@ -65,7 +66,7 @@ from services.agent.observability_service import ( from services.agent.roster_service import AgentRosterService from services.app_service import AppListParams, AppService, CreateAppParams from services.enterprise.enterprise_service import EnterpriseService -from services.entities.agent_entities import RosterListQuery +from services.entities.agent_entities import ComposerSavePayload, RosterListQuery from services.feature_service import FeatureService @@ -250,6 +251,36 @@ class AgentDebugConversationRefreshResponse(BaseModel): debug_conversation_id: str +class AgentPublishPayload(BaseModel): + version_note: str | None = Field(default=None, description="Optional note for this published Agent version") + + +class AgentPublishResponse(BaseModel): + result: str + active_config_snapshot_id: str + active_config_snapshot: dict[str, object] | None = None + draft: dict[str, object] | None = None + + +class AgentBuildDraftCheckoutPayload(BaseModel): + force: bool = Field(default=False, description="Overwrite the existing current-user build draft") + + +class AgentBuildDraftResponse(BaseModel): + variant: str + draft: dict[str, object] + agent_soul: dict[str, object] + + +class AgentBuildDraftApplyResponse(BaseModel): + result: str + draft: dict[str, object] + + +class AgentSimpleResultResponse(BaseModel): + result: str + + class AgentAppPagination(GenericAppPagination): data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute] validation_alias=AliasChoices("items", "data") @@ -261,6 +292,9 @@ register_schema_models( AgentAppCreatePayload, AgentAppUpdatePayload, AgentAppCopyPayload, + AgentPublishPayload, + AgentBuildDraftCheckoutPayload, + ComposerSavePayload, AgentApiStatusPayload, AgentInviteOptionsQuery, AgentLogsQuery, @@ -277,6 +311,10 @@ register_response_schema_models( AgentAppDetailWithSite, AgentAppPartial, AgentDebugConversationRefreshResponse, + AgentPublishResponse, + AgentBuildDraftResponse, + AgentBuildDraftApplyResponse, + AgentSimpleResultResponse, AgentConfigSnapshotDetailResponse, AgentConfigSnapshotListResponse, AgentConfigSnapshotRestoreResponse, @@ -583,6 +621,112 @@ class AgentDebugConversationRefreshApi(Resource): ) +@console_ns.route("/agent//publish") +class AgentPublishApi(Resource): + @console_ns.expect(console_ns.models[AgentPublishPayload.__name__]) + @console_ns.response(200, "Agent draft published", console_ns.models[AgentPublishResponse.__name__]) + @console_ns.response(403, "Insufficient permissions") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + args = AgentPublishPayload.model_validate(console_ns.payload or {}) + return AgentComposerService.publish_agent_app_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + version_note=args.version_note, + ) + + +@console_ns.route("/agent//build-draft/checkout") +class AgentBuildDraftCheckoutApi(Resource): + @console_ns.expect(console_ns.models[AgentBuildDraftCheckoutPayload.__name__]) + @console_ns.response(200, "Agent build draft checked out", console_ns.models[AgentBuildDraftResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + args = AgentBuildDraftCheckoutPayload.model_validate(console_ns.payload or {}) + return AgentComposerService.checkout_agent_app_build_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + force=args.force, + ) + + +@console_ns.route("/agent//build-draft") +class AgentBuildDraftApi(Resource): + @console_ns.response(200, "Agent build draft", console_ns.models[AgentBuildDraftResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): + return AgentComposerService.load_agent_app_build_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + ) + + @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) + @console_ns.response(200, "Agent build draft saved", console_ns.models[AgentBuildDraftResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def put(self, tenant_id: str, current_user: Account, agent_id: UUID): + payload = ComposerSavePayload.model_validate(console_ns.payload or {}) + return AgentComposerService.save_agent_app_build_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + payload=payload, + ) + + @console_ns.response(200, "Agent build draft discarded", console_ns.models[AgentSimpleResultResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def delete(self, tenant_id: str, current_user: Account, agent_id: UUID): + return AgentComposerService.discard_agent_app_build_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + ) + + +@console_ns.route("/agent//build-draft/apply") +class AgentBuildDraftApplyApi(Resource): + @console_ns.response(200, "Agent build draft applied", console_ns.models[AgentBuildDraftApplyResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + return AgentComposerService.apply_agent_app_build_draft( + tenant_id=tenant_id, + agent_id=str(agent_id), + account_id=current_user.id, + ) + + @console_ns.route("/agent//copy") class AgentAppCopyApi(Resource): @console_ns.expect(console_ns.models[AgentAppCopyPayload.__name__]) diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index 545fad34cde..d25d0a5c4d2 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -93,6 +93,10 @@ class ChatMessagePayload(BaseMessagePayload): query: str = Field(..., description="User query") conversation_id: str | None = Field(default=None, description="Conversation ID") parent_message_id: str | None = Field(default=None, description="Parent message ID") + draft_type: Literal["draft", "debug_build"] = Field( + default="draft", + description="Agent App debug config source. Use debug_build while the Agent is in build mode.", + ) @field_validator("conversation_id", "parent_message_id") @classmethod diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index f816b1fa477..bd8012a0f24 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -42,7 +42,15 @@ from core.app.llm.model_access import build_dify_model_access from core.ops.ops_trace_manager import TraceQueueManager from extensions.ext_database import db from models import Account, App, EndUser, Message -from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus +from models.agent import ( + Agent, + AgentConfigDraft, + AgentConfigDraftType, + AgentConfigSnapshot, + AgentScope, + AgentSource, + AgentStatus, +) from models.agent_config_entities import AgentSoulConfig from services.conversation_service import ConversationService @@ -73,10 +81,15 @@ class AgentAppGenerator(MessageBasedAppGenerator): inputs = args["inputs"] # Resolve the bound roster Agent + its current Agent Soul snapshot. - agent, snapshot, agent_soul = self._resolve_agent(app_model) + agent, agent_config_id, agent_soul = self._resolve_agent( + app_model, + invoke_from=invoke_from, + draft_type=args.get("draft_type"), + user=user, + ) runtime_session_snapshot_id = self._runtime_session_snapshot_id( invoke_from=invoke_from, - snapshot_id=snapshot.id, + snapshot_id=agent_config_id, ) conversation = None @@ -123,7 +136,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): call_depth=0, trace_manager=trace_manager, agent_id=agent.id, - agent_config_snapshot_id=snapshot.id, + agent_config_snapshot_id=agent_config_id, agent_runtime_session_snapshot_id=runtime_session_snapshot_id, ) @@ -179,7 +192,12 @@ class AgentAppGenerator(MessageBasedAppGenerator): persisted to the conversation. Live streaming to a reconnected client is out of scope here — the message is persisted and can be re-fetched. """ - agent, snapshot, agent_soul = self._resolve_agent(app_model) + agent, agent_config_id, agent_soul = self._resolve_agent( + app_model, + invoke_from=invoke_from, + draft_type="draft", + user=user, + ) conversation = ConversationService.get_conversation( app_model=app_model, conversation_id=conversation_id, user=user ) @@ -226,7 +244,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): call_depth=0, trace_manager=trace_manager, agent_id=agent.id, - agent_config_snapshot_id=snapshot.id, + agent_config_snapshot_id=agent_config_id, ) conversation, message = self._init_generate_records(application_generate_entity, conversation) @@ -421,7 +439,14 @@ class AgentAppGenerator(MessageBasedAppGenerator): return False, query - def _resolve_agent(self, app_model: App) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]: + def _resolve_agent( + self, + app_model: App, + *, + invoke_from: InvokeFrom, + draft_type: Any, + user: Account | EndUser, + ) -> tuple[Agent, str, AgentSoulConfig]: agent = db.session.scalar( select(Agent).where( Agent.app_id == app_model.id, @@ -432,39 +457,108 @@ class AgentAppGenerator(MessageBasedAppGenerator): ) if agent is None: raise AgentAppGeneratorError("Agent App has no bound Agent") - return self._resolve_agent_by_id( - tenant_id=app_model.tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id + if invoke_from == InvokeFrom.DEBUGGER: + draft = self._resolve_debug_draft( + tenant_id=app_model.tenant_id, + agent=agent, + draft_type=draft_type, + account_id=user.id if isinstance(user, Account) else None, + ) + agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict) + return agent, draft.id, agent_soul + _, snapshot, agent_soul = self._resolve_agent_by_id( + tenant_id=app_model.tenant_id, + agent_id=agent.id, + snapshot_id=agent.active_config_snapshot_id, ) + return agent, snapshot.id, agent_soul @staticmethod def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None: """Return the session scope snapshot id for Agent App runtime state. - Console preview/debug chat is an editing workspace: saving Agent Soul - creates replacement snapshots, but the user expects the same preview - conversation to keep context while trying prompt changes. Use a stable - NULL snapshot scope for debugger runs so each turn can use the latest - Agent Soul while reusing the conversation history. Published/web/API - runs keep snapshot-scoped sessions for reproducible runtime state. + Console preview/debug chat uses a stable Agent draft row id; build mode + uses the current user's build-draft row id. Published/web/API runs use + immutable published snapshot ids. This keeps runtime session continuity + inside one editable surface without mixing draft/build/published state. """ - if invoke_from == InvokeFrom.DEBUGGER: - return None return snapshot_id + @staticmethod + def _resolve_debug_draft( + *, tenant_id: str, agent: Agent, draft_type: Any, account_id: str | None + ) -> AgentConfigDraft: + effective_draft_type = ( + AgentConfigDraftType.DEBUG_BUILD + if draft_type == AgentConfigDraftType.DEBUG_BUILD.value + else AgentConfigDraftType.DRAFT + ) + stmt = select(AgentConfigDraft).where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id == agent.id, + AgentConfigDraft.draft_type == effective_draft_type, + ) + if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD: + if not account_id: + raise AgentAppGeneratorError("Build draft requires an account user") + stmt = stmt.where(AgentConfigDraft.account_id == account_id) + else: + stmt = stmt.where(AgentConfigDraft.account_id.is_(None)) + draft = db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1)) + if draft is not None: + return draft + if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD: + raise AgentAppGeneratorError("Agent build draft not found") + _, snapshot, agent_soul = AgentAppGenerator._resolve_agent_by_id( + tenant_id=tenant_id, + agent_id=agent.id, + snapshot_id=agent.active_config_snapshot_id, + ) + draft = AgentConfigDraft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", + base_snapshot_id=snapshot.id, + config_snapshot=agent_soul, + created_by=agent.created_by, + updated_by=agent.updated_by, + ) + db.session.add(draft) + db.session.flush() + return draft + @staticmethod def _resolve_agent_by_id( *, tenant_id: str, agent_id: str, snapshot_id: str | None - ) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]: + ) -> tuple[Agent, AgentConfigSnapshot | AgentConfigDraft, AgentSoulConfig]: agent = db.session.scalar(select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id)) if agent is None: raise AgentAppGeneratorError("Agent not found") if not snapshot_id: raise AgentAppGeneratorError("Agent has no published version") - snapshot = db.session.scalar(select(AgentConfigSnapshot).where(AgentConfigSnapshot.id == snapshot_id)) - if snapshot is None: + snapshot = db.session.scalar( + select(AgentConfigSnapshot).where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id == snapshot_id, + ) + ) + if snapshot is not None: + agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) + return agent, snapshot, agent_soul + draft = db.session.scalar( + select(AgentConfigDraft).where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id == agent_id, + AgentConfigDraft.id == snapshot_id, + ) + ) + if draft is None: raise AgentAppGeneratorError("Agent published version not found") - agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) - return agent, snapshot, agent_soul + agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict) + return agent, draft, agent_soul __all__ = ["AgentAppGenerator", "AgentAppGeneratorError"] diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py index 204be4d412e..6f6ce5c6b4a 100644 --- a/api/fields/agent_fields.py +++ b/api/fields/agent_fields.py @@ -6,6 +6,7 @@ from pydantic import Field, field_validator from fields.base import ResponseModel from libs.helper import to_timestamp from models.agent import ( + AgentConfigDraftType, AgentConfigRevisionOperation, AgentIconType, AgentKind, @@ -49,6 +50,18 @@ class AgentConfigSnapshotSummaryResponse(ResponseModel): created_at: int | None = None +class AgentConfigDraftSummaryResponse(ResponseModel): + id: str + agent_id: str + draft_type: AgentConfigDraftType + account_id: str | None = None + base_snapshot_id: str | None = None + created_by: str | None = None + updated_by: str | None = None + created_at: int | None = None + updated_at: int | None = None + + class AgentPublishedReferenceResponse(ResponseModel): app_id: str app_name: str @@ -294,6 +307,8 @@ class AgentConfigSnapshotListResponse(ResponseModel): class AgentConfigSnapshotRestoreResponse(ResponseModel): result: Literal["success"] active_config_snapshot_id: str + draft_config_id: str | None = None + restored_version_id: str | None = None class AgentComposerAgentResponse(ResponseModel): @@ -356,7 +371,8 @@ class WorkflowAgentComposerResponse(ResponseModel): class AgentAppComposerResponse(ResponseModel): variant: Literal[ComposerVariant.AGENT_APP] agent: AgentComposerAgentResponse - active_config_snapshot: AgentConfigSnapshotSummaryResponse + active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None + draft: AgentConfigDraftSummaryResponse | None = None agent_soul: AgentSoulConfig save_options: list[ComposerSaveStrategy] validation: "ComposerValidationFindingsResponse | None" = None diff --git a/api/migrations/versions/2026_06_24_2015-e4f5a6b7c8d9_add_agent_config_drafts.py b/api/migrations/versions/2026_06_24_2015-e4f5a6b7c8d9_add_agent_config_drafts.py new file mode 100644 index 00000000000..2a76094721d --- /dev/null +++ b/api/migrations/versions/2026_06_24_2015-e4f5a6b7c8d9_add_agent_config_drafts.py @@ -0,0 +1,135 @@ +"""add agent config drafts + +Revision ID: e4f5a6b7c8d9 +Revises: d9e8f7a6b5c4 +Create Date: 2026-06-24 20:15:00.000000 + +""" + +from datetime import UTC, datetime + +import sqlalchemy as sa +from alembic import op + +import models +from libs.uuid_utils import uuidv7 + +# revision identifiers, used by Alembic. +revision = "e4f5a6b7c8d9" +down_revision = "d9e8f7a6b5c4" +branch_labels = None +depends_on = None + + +def _is_pg(conn) -> bool: + return conn.dialect.name == "postgresql" + + +def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column: + kwargs = {"nullable": nullable, "primary_key": primary_key} + if primary_key and _is_pg(op.get_bind()): + kwargs["server_default"] = sa.text("uuidv7()") + return sa.Column(name, models.types.StringUUID(), **kwargs) + + +def upgrade(): + op.create_table( + "agent_config_drafts", + _uuid_column("id", primary_key=True), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("agent_id", models.types.StringUUID(), nullable=False), + sa.Column("draft_type", sa.String(length=32), nullable=False), + sa.Column("account_id", models.types.StringUUID(), nullable=True), + sa.Column("draft_owner_key", sa.String(length=255), server_default="", nullable=False), + sa.Column("base_snapshot_id", models.types.StringUUID(), nullable=True), + sa.Column("config_snapshot", models.types.LongText(), nullable=False), + sa.Column("created_by", models.types.StringUUID(), nullable=True), + sa.Column("updated_by", models.types.StringUUID(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("agent_config_draft_pkey")), + sa.UniqueConstraint( + "tenant_id", + "agent_id", + "draft_type", + "draft_owner_key", + name=op.f("agent_config_draft_agent_type_account_unique"), + ), + ) + op.create_index("agent_config_draft_tenant_agent_idx", "agent_config_drafts", ["tenant_id", "agent_id"]) + op.create_index( + "agent_config_draft_base_snapshot_idx", + "agent_config_drafts", + ["tenant_id", "base_snapshot_id"], + ) + + bind = op.get_bind() + now = datetime.now(UTC).replace(tzinfo=None) + if bind.dialect.name == "postgresql": + op.execute( + sa.text( + """ + INSERT INTO agent_config_drafts ( + id, tenant_id, agent_id, draft_type, account_id, draft_owner_key, base_snapshot_id, + config_snapshot, created_by, updated_by, created_at, updated_at + ) + SELECT + uuidv7(), a.tenant_id, a.id, 'draft', NULL, '', s.id, + s.config_snapshot, a.created_by, a.updated_by, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM agents a + JOIN agent_config_snapshots s + ON s.tenant_id = a.tenant_id + AND s.agent_id = a.id + AND s.id = a.active_config_snapshot_id + WHERE a.active_config_snapshot_id IS NOT NULL + """ + ) + ) + else: + agents = bind.execute( + sa.text( + """ + SELECT + a.tenant_id, a.id AS agent_id, a.created_by, a.updated_by, + s.id AS snapshot_id, s.config_snapshot + FROM agents a + JOIN agent_config_snapshots s + ON s.tenant_id = a.tenant_id + AND s.agent_id = a.id + AND s.id = a.active_config_snapshot_id + WHERE a.active_config_snapshot_id IS NOT NULL + """ + ) + ).mappings() + for row in agents: + bind.execute( + sa.text( + """ + INSERT INTO agent_config_drafts ( + id, tenant_id, agent_id, draft_type, account_id, draft_owner_key, base_snapshot_id, + config_snapshot, created_by, updated_by, created_at, updated_at + ) + VALUES ( + :id, :tenant_id, :agent_id, 'draft', NULL, '', :snapshot_id, + :config_snapshot, :created_by, :updated_by, :created_at, :updated_at + ) + """ + ), + { + "id": str(uuidv7()), + "tenant_id": row["tenant_id"], + "agent_id": row["agent_id"], + "snapshot_id": row["snapshot_id"], + "config_snapshot": row["config_snapshot"], + "created_by": row["created_by"], + "updated_by": row["updated_by"], + "created_at": now, + "updated_at": now, + }, + ) + + +def downgrade(): + op.drop_index("agent_config_draft_base_snapshot_idx", table_name="agent_config_drafts") + op.drop_index("agent_config_draft_tenant_agent_idx", table_name="agent_config_drafts") + op.drop_table("agent_config_drafts") diff --git a/api/models/__init__.py b/api/models/__init__.py index 9992de982c4..ac90eef0962 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -10,6 +10,8 @@ from .account import ( ) from .agent import ( Agent, + AgentConfigDraft, + AgentConfigDraftType, AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, @@ -154,6 +156,8 @@ __all__ = [ "AccountStatus", "AccountTrialAppRecord", "Agent", + "AgentConfigDraft", + "AgentConfigDraftType", "AgentConfigRevision", "AgentConfigRevisionOperation", "AgentConfigSnapshot", diff --git a/api/models/agent.py b/api/models/agent.py index 46044edd5e7..2e10118ff61 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -85,6 +85,17 @@ class AgentConfigRevisionOperation(StrEnum): SAVE_TO_ROSTER = "save_to_roster" # Switches the Agent's current published config back to an existing version. RESTORE_VERSION = "restore_version" + # Publishes the editable Agent Soul draft as a new immutable version. + PUBLISH_DRAFT = "publish_draft" + + +class AgentConfigDraftType(StrEnum): + """Editable Agent Soul draft workspace type.""" + + # Shared Agent Console draft edited by users before publishing. + DRAFT = "draft" + # Per-editor build draft mutated during debug/build mode. + DEBUG_BUILD = "debug_build" class WorkflowAgentBindingType(StrEnum): @@ -210,6 +221,46 @@ class AgentDebugConversation(DefaultFieldsMixin, Base): conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False) +class AgentConfigDraft(DefaultFieldsMixin, Base): + """Editable Agent Soul draft separated from immutable published snapshots.""" + + __tablename__ = "agent_config_drafts" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="agent_config_draft_pkey"), + UniqueConstraint( + "tenant_id", + "agent_id", + "draft_type", + "draft_owner_key", + name="agent_config_draft_agent_type_account_unique", + ), + Index("agent_config_draft_tenant_agent_idx", "tenant_id", "agent_id"), + Index("agent_config_draft_base_snapshot_idx", "tenant_id", "base_snapshot_id"), + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + draft_type: Mapped[AgentConfigDraftType] = mapped_column( + EnumText(AgentConfigDraftType, length=32), nullable=False + ) + account_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + draft_owner_key: Mapped[str] = mapped_column(String(255), nullable=False, default="") + base_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + config_snapshot: Mapped[Any] = mapped_column(JSONModelColumn(AgentSoulConfig), nullable=False) + created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + updated_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 AgentConfigSnapshot(DefaultFieldsMixin, Base): """Immutable Agent Soul snapshot. @@ -355,9 +406,9 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base): agent_config_snapshot_id / composition_layer_specs`` columns are set. - Agent App conversations: ``owner_type = conversation``; the ``conversation_id`` column is set and the workflow columns stay NULL. - Published/web/API runs scope runtime state by ``agent_config_snapshot_id``; - console debugger runs may keep it NULL so prompt-only draft saves can reuse - the same preview conversation state while executing the latest Agent Soul. + Runtime state is scoped by ``agent_config_snapshot_id``. For published + web/API runs this points to an immutable AgentConfigSnapshot; for console + debugger/build runs it points to the editable AgentConfigDraft row. The snapshot is runtime state returned by Agent backend, kept separate from Agent Soul snapshots and workflow node-job config. diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 3fefb9b6ffc..30910a0f4d9 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -11,6 +11,8 @@ from libs.helper import to_timestamp from models import Account from models.agent import ( Agent, + AgentConfigDraft, + AgentConfigDraftType, AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, @@ -265,27 +267,23 @@ class AgentComposerService: @classmethod def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]: - agent = db.session.scalar( - select(Agent) - .where( - Agent.tenant_id == tenant_id, - Agent.app_id == app_id, - Agent.scope == AgentScope.ROSTER, - Agent.status == AgentStatus.ACTIVE, - ) - .order_by(Agent.created_at.desc()) - .limit(1) + agent = cls._require_agent_app_agent(tenant_id=tenant_id, app_id=app_id) + draft = cls._get_or_create_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + created_by=agent.updated_by or agent.created_by, ) - if not agent: - raise AgentNotFoundError() - version = cls._require_version( + version = cls._get_version_if_present( tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id ) return { "variant": ComposerVariant.AGENT_APP.value, "agent": cls._serialize_agent(agent), "active_config_snapshot": cls._serialize_version(version), - "agent_soul": version.config_snapshot_dict, + "draft": cls._serialize_draft(draft), + "agent_soul": draft.config_snapshot_dict, "save_options": [ ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value, @@ -304,17 +302,7 @@ class AgentComposerService: if payload.agent_soul is None: raise ValueError("agent_soul is required") - agent = db.session.scalar( - select(Agent) - .where( - Agent.tenant_id == tenant_id, - Agent.app_id == app_id, - Agent.scope == AgentScope.ROSTER, - Agent.status == AgentStatus.ACTIVE, - ) - .order_by(Agent.created_at.desc()) - .limit(1) - ) + agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id) if not agent: agent = Agent( tenant_id=tenant_id, @@ -334,37 +322,20 @@ class AgentComposerService: except IntegrityError as exc: db.session.rollback() raise AgentNameConflictError() from exc - payload.agent_soul = cls._preserve_active_soul_files( + payload.agent_soul = cls._preserve_agent_draft_soul_files( tenant_id=tenant_id, agent_id=agent.id, agent_soul=payload.agent_soul, ) - - if payload.save_strategy == ComposerSaveStrategy.SAVE_AS_NEW_VERSION or not agent.active_config_snapshot_id: - version = cls._create_config_version( - tenant_id=tenant_id, - agent_id=agent.id, - account_id=account_id, - agent_soul=payload.agent_soul, - operation=AgentConfigRevisionOperation.SAVE_NEW_VERSION, - version_note=payload.version_note, - ) - agent.active_config_snapshot_id = version.id - agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) - else: - current_snapshot = cls._require_version( - tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id - ) - version = cls._update_current_version( - current_snapshot=current_snapshot, - account_id=account_id, - agent_soul=payload.agent_soul, - operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION, - version_note=payload.version_note, - ) - agent.active_config_snapshot_id = version.id - agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) - agent.updated_by = account_id + cls._save_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + agent_soul=payload.agent_soul, + account_id_for_audit=account_id, + ) + agent.updated_by = account_id db.session.commit() state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id) @@ -375,6 +346,167 @@ class AgentComposerService: ) return state + @classmethod + def publish_agent_app_draft( + cls, *, tenant_id: str, agent_id: str, account_id: str, version_note: str | None = None + ) -> dict[str, Any]: + agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) + if agent.scope != AgentScope.ROSTER or agent.source != AgentSource.AGENT_APP: + raise AgentNotFoundError() + draft = cls._get_or_create_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + created_by=account_id, + ) + agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict) + ComposerConfigValidator.validate_publish_payload( + ComposerSavePayload( + variant=ComposerVariant.AGENT_APP, + agent_soul=agent_soul, + save_strategy=ComposerSaveStrategy.SAVE_AS_NEW_VERSION, + version_note=version_note, + ) + ) + cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=agent_soul) + version = cls._create_config_version( + tenant_id=tenant_id, + agent_id=agent.id, + account_id=account_id, + agent_soul=agent_soul, + operation=AgentConfigRevisionOperation.PUBLISH_DRAFT, + version_note=version_note, + previous_snapshot_id=agent.active_config_snapshot_id, + ) + agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(agent_soul) + agent.updated_by = account_id + draft.base_snapshot_id = version.id + draft.updated_by = account_id + db.session.commit() + return { + "result": "success", + "active_config_snapshot_id": version.id, + "active_config_snapshot": cls._serialize_version(version), + "draft": cls._serialize_draft(draft), + } + + @classmethod + def checkout_agent_app_build_draft( + cls, *, tenant_id: str, agent_id: str, account_id: str, force: bool = False + ) -> dict[str, Any]: + agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) + if agent.scope != AgentScope.ROSTER or agent.source != AgentSource.AGENT_APP: + raise AgentNotFoundError() + normal_draft = cls._get_or_create_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + created_by=account_id, + ) + build_draft = cls._get_agent_draft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + ) + if build_draft is not None and not force: + return cls._serialize_build_draft_state(build_draft) + if build_draft is None: + build_draft = AgentConfigDraft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + draft_owner_key=account_id, + created_by=account_id, + ) + db.session.add(build_draft) + build_draft.base_snapshot_id = normal_draft.base_snapshot_id + build_draft.config_snapshot = AgentSoulConfig.model_validate(normal_draft.config_snapshot_dict) + build_draft.updated_by = account_id + db.session.commit() + return cls._serialize_build_draft_state(build_draft) + + @classmethod + def load_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]: + build_draft = cls._get_agent_draft( + tenant_id=tenant_id, + agent_id=agent_id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + ) + if build_draft is None: + raise AgentVersionNotFoundError() + return cls._serialize_build_draft_state(build_draft) + + @classmethod + def save_agent_app_build_draft( + cls, *, tenant_id: str, agent_id: str, account_id: str, payload: ComposerSavePayload + ) -> dict[str, Any]: + if payload.agent_soul is None: + raise ValueError("agent_soul is required") + _backfill_cli_tool_ids(payload.agent_soul) + ComposerConfigValidator.validate_draft_save_payload(payload) + cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) + agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) + payload.agent_soul = cls._preserve_agent_draft_soul_files( + tenant_id=tenant_id, + agent_id=agent.id, + agent_soul=payload.agent_soul, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + ) + build_draft = cls._save_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + agent_soul=payload.agent_soul, + account_id_for_audit=account_id, + ) + db.session.commit() + return cls._serialize_build_draft_state(build_draft) + + @classmethod + def apply_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]: + agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id) + build_draft = cls._get_agent_draft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + ) + if build_draft is None: + raise AgentVersionNotFoundError() + normal_draft = cls._save_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + agent_soul=AgentSoulConfig.model_validate(build_draft.config_snapshot_dict), + account_id_for_audit=account_id, + base_snapshot_id=build_draft.base_snapshot_id, + ) + db.session.delete(build_draft) + db.session.commit() + return {"result": "success", "draft": cls._serialize_draft(normal_draft)} + + @classmethod + def discard_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]: + build_draft = cls._get_agent_draft( + tenant_id=tenant_id, + agent_id=agent_id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=account_id, + ) + if build_draft is not None: + db.session.delete(build_draft) + db.session.commit() + return {"result": "success"} + @classmethod def collect_validation_findings( cls, @@ -619,23 +751,17 @@ class AgentComposerService: @classmethod def _load_agent_app_soul(cls, *, tenant_id: str, app_id: str) -> AgentSoulConfig | None: - agent = db.session.scalar( - select(Agent) - .where( - Agent.tenant_id == tenant_id, - Agent.app_id == app_id, - Agent.scope == AgentScope.ROSTER, - Agent.status == AgentStatus.ACTIVE, - ) - .order_by(Agent.created_at.desc()) - .limit(1) - ) + agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id) if agent is None: return None - version = cls._get_version_if_present( - tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id + draft = cls._get_or_create_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + created_by=agent.updated_by or agent.created_by, ) - return cls._parse_soul_snapshot(version) + return AgentSoulConfig.model_validate(draft.config_snapshot_dict) @staticmethod def _parse_soul_snapshot(version: AgentConfigSnapshot | None) -> AgentSoulConfig | None: @@ -1206,6 +1332,31 @@ class AgentComposerService: preserved.files = existing_soul.files return preserved + @classmethod + def _preserve_agent_draft_soul_files( + cls, + *, + tenant_id: str, + agent_id: str | None, + agent_soul: AgentSoulConfig, + draft_type: AgentConfigDraftType = AgentConfigDraftType.DRAFT, + account_id: str | None = None, + ) -> AgentSoulConfig: + if not agent_id: + return agent_soul + draft = cls._get_agent_draft( + tenant_id=tenant_id, + agent_id=agent_id, + draft_type=draft_type, + account_id=account_id, + ) + if draft is not None: + existing_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict) + preserved = agent_soul.model_copy(deep=True) + preserved.files = existing_soul.files + return preserved + return cls._preserve_active_soul_files(tenant_id=tenant_id, agent_id=agent_id, agent_soul=agent_soul) + @staticmethod def _drive_copy_scopes_from_agent_configs( *, agent_soul: AgentSoulConfig, node_job: WorkflowNodeJobConfig | None = None @@ -1356,6 +1507,143 @@ class AgentComposerService: or 0 ) + 1 + @classmethod + def _get_agent_app_agent(cls, *, tenant_id: str, app_id: str) -> Agent | None: + return db.session.scalar( + select(Agent) + .where( + Agent.tenant_id == tenant_id, + Agent.app_id == app_id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.AGENT_APP, + Agent.status == AgentStatus.ACTIVE, + ) + .order_by(Agent.created_at.desc()) + .limit(1) + ) + + @classmethod + def _require_agent_app_agent(cls, *, tenant_id: str, app_id: str) -> Agent: + agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id) + if agent is None: + raise AgentNotFoundError() + return agent + + @classmethod + def _get_agent_draft( + cls, + *, + tenant_id: str, + agent_id: str, + draft_type: AgentConfigDraftType, + account_id: str | None, + ) -> AgentConfigDraft | None: + stmt = select(AgentConfigDraft).where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id == agent_id, + AgentConfigDraft.draft_type == draft_type, + ) + if draft_type == AgentConfigDraftType.DEBUG_BUILD: + stmt = stmt.where(AgentConfigDraft.account_id == account_id) + else: + stmt = stmt.where(AgentConfigDraft.account_id.is_(None)) + return db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1)) + + @classmethod + def _get_or_create_agent_draft( + cls, + *, + tenant_id: str, + agent: Agent, + draft_type: AgentConfigDraftType, + account_id: str | None, + created_by: str | None, + ) -> AgentConfigDraft: + draft = cls._get_agent_draft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=draft_type, + account_id=account_id, + ) + if draft is not None: + return draft + base_snapshot = cls._get_version_if_present( + tenant_id=tenant_id, + agent_id=agent.id, + version_id=agent.active_config_snapshot_id, + ) + agent_soul = ( + AgentSoulConfig.model_validate(base_snapshot.config_snapshot_dict) + if base_snapshot is not None + else AgentSoulConfig() + ) + draft = AgentConfigDraft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=draft_type, + account_id=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD else None, + draft_owner_key=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD and account_id else "", + base_snapshot_id=base_snapshot.id if base_snapshot else None, + config_snapshot=agent_soul, + created_by=created_by, + updated_by=created_by, + ) + db.session.add(draft) + db.session.flush() + return draft + + @classmethod + def _save_agent_draft( + cls, + *, + tenant_id: str, + agent: Agent, + draft_type: AgentConfigDraftType, + account_id: str | None, + agent_soul: AgentSoulConfig, + account_id_for_audit: str, + base_snapshot_id: str | None = None, + ) -> AgentConfigDraft: + draft = cls._get_or_create_agent_draft( + tenant_id=tenant_id, + agent=agent, + draft_type=draft_type, + account_id=account_id, + created_by=account_id_for_audit, + ) + draft.config_snapshot = agent_soul + if base_snapshot_id is not None: + draft.base_snapshot_id = base_snapshot_id + elif draft.base_snapshot_id is None: + draft.base_snapshot_id = agent.active_config_snapshot_id + draft.updated_by = account_id_for_audit + db.session.flush() + return draft + + @classmethod + def _serialize_draft(cls, draft: AgentConfigDraft | None) -> dict[str, Any] | None: + if draft is None: + return None + return { + "id": draft.id, + "agent_id": draft.agent_id, + "draft_type": draft.draft_type.value, + "account_id": draft.account_id, + "base_snapshot_id": draft.base_snapshot_id, + "created_by": draft.created_by, + "updated_by": draft.updated_by, + "created_at": to_timestamp(draft.created_at), + "updated_at": to_timestamp(draft.updated_at), + } + + @classmethod + def _serialize_build_draft_state(cls, draft: AgentConfigDraft) -> dict[str, Any]: + return { + "variant": ComposerVariant.AGENT_APP.value, + "draft": cls._serialize_draft(draft), + "agent_soul": draft.config_snapshot_dict, + } + @classmethod def _get_draft_workflow(cls, *, tenant_id: str, app_id: str) -> Workflow: workflow = db.session.scalar( diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index 97d91b50770..d07a757a416 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -8,6 +8,8 @@ from libs.datetime_utils import naive_utc_now from libs.helper import to_timestamp from models.agent import ( Agent, + AgentConfigDraft, + AgentConfigDraftType, AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, @@ -836,6 +838,7 @@ class AgentRosterService: def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]: if agent.source == AgentSource.AGENT_APP: return { + AgentConfigRevisionOperation.PUBLISH_DRAFT, AgentConfigRevisionOperation.SAVE_NEW_VERSION, AgentConfigRevisionOperation.SAVE_TO_ROSTER, AgentConfigRevisionOperation.RESTORE_VERSION, @@ -849,16 +852,33 @@ class AgentRosterService: } def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool: - """Return whether the Agent's current active snapshot is a visible published version.""" + """Return whether the editable draft matches the active published snapshot.""" return self.load_active_config_is_published_by_agent_id(tenant_id=tenant_id, agents=[agent]).get( agent.id, False, ) def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]: - """Return publish-state flags for the active config snapshots of the given Agents.""" + """Return whether each Agent's normal draft is aligned with its active published snapshot.""" published_agent_ids = self._load_published_active_snapshot_agent_ids(tenant_id=tenant_id, agents=agents) - return {agent.id: agent.id in published_agent_ids for agent in agents} + drafts = self._session.scalars( + select(AgentConfigDraft).where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id.in_([agent.id for agent in agents] or [""]), + AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT, + AgentConfigDraft.account_id.is_(None), + ) + ).all() + drafts_by_agent_id = {draft.agent_id: draft for draft in drafts} + result: dict[str, bool] = {} + for agent in agents: + draft = drafts_by_agent_id.get(agent.id) + result[agent.id] = ( + agent.id in published_agent_ids + and bool(agent.active_config_snapshot_id) + and (draft is None or draft.base_snapshot_id == agent.active_config_snapshot_id) + ) + return result def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[dict[str, Any]]: agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) @@ -957,26 +977,37 @@ class AgentRosterService: raise AgentVersionNotFoundError() version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id) - if agent.active_config_snapshot_id == version.id: - return {"result": "success", "active_config_snapshot_id": version.id} - - previous_snapshot_id = agent.active_config_snapshot_id - agent.active_config_snapshot_id = version.id - agent.active_config_has_model = agent_soul_has_model(version.config_snapshot) - agent.updated_by = account_id - self._session.add( - AgentConfigRevision( + draft = self._session.scalar( + select(AgentConfigDraft) + .where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id == agent_id, + AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT, + AgentConfigDraft.account_id.is_(None), + ) + .limit(1) + ) + if draft is None: + draft = AgentConfigDraft( tenant_id=tenant_id, agent_id=agent_id, - previous_snapshot_id=previous_snapshot_id, - current_snapshot_id=version.id, - revision=self._next_revision(tenant_id=tenant_id, agent_id=agent_id), - operation=AgentConfigRevisionOperation.RESTORE_VERSION, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", created_by=account_id, ) - ) + self._session.add(draft) + draft.base_snapshot_id = version.id + draft.config_snapshot = AgentSoulConfig.model_validate(version.config_snapshot_dict) + draft.updated_by = account_id + agent.updated_by = account_id self._session.commit() - return {"result": "success", "active_config_snapshot_id": version.id} + return { + "result": "success", + "active_config_snapshot_id": agent.active_config_snapshot_id or version.id, + "draft_config_id": draft.id, + "restored_version_id": version.id, + } def _get_agent(self, *, tenant_id: str, agent_id: str, roster_only: bool = False) -> Agent: stmt = select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id) diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index 3d84f899379..9c60da00216 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -28,11 +28,15 @@ from controllers.console.agent.roster import ( AgentAppApi, AgentAppCopyApi, AgentAppListApi, + AgentBuildDraftApi, + AgentBuildDraftApplyApi, + AgentBuildDraftCheckoutApi, AgentDebugConversationRefreshApi, AgentInviteOptionsApi, AgentLogMessagesApi, AgentLogsApi, AgentLogSourcesApi, + AgentPublishApi, AgentRosterVersionDetailApi, AgentRosterVersionRestoreApi, AgentRosterVersionsApi, @@ -151,6 +155,10 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None: "/agent//composer/candidates", "/agent//features", "/agent//copy", + "/agent//publish", + "/agent//build-draft/checkout", + "/agent//build-draft", + "/agent//build-draft/apply", "/agent//referencing-workflows", "/agent//drive/files", "/agent//sandbox/files", @@ -520,6 +528,129 @@ def test_agent_debug_conversation_refresh_uses_current_user( } +def test_agent_publish_and_build_draft_routes_call_composer_service( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + current_user = SimpleNamespace(id=account_id) + captured: dict[str, object] = {} + + def publish_agent_app_draft(**kwargs: object) -> dict[str, object]: + captured["publish"] = kwargs + return {"result": "success", "active_config_snapshot_id": "version-1"} + + def checkout_agent_app_build_draft(**kwargs: object) -> dict[str, object]: + captured["checkout"] = kwargs + return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}} + + def load_agent_app_build_draft(**kwargs: object) -> dict[str, object]: + captured["load"] = kwargs + return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}} + + def save_agent_app_build_draft(**kwargs: object) -> dict[str, object]: + captured["save"] = kwargs + return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}} + + def apply_agent_app_build_draft(**kwargs: object) -> dict[str, object]: + captured["apply"] = kwargs + return {"result": "success", "draft": {"id": "draft-1"}} + + def discard_agent_app_build_draft(**kwargs: object) -> dict[str, object]: + captured["discard"] = kwargs + return {"result": "success"} + + monkeypatch.setattr( + roster_controller.AgentComposerService, + "publish_agent_app_draft", + publish_agent_app_draft, + ) + monkeypatch.setattr( + roster_controller.AgentComposerService, + "checkout_agent_app_build_draft", + checkout_agent_app_build_draft, + ) + monkeypatch.setattr( + roster_controller.AgentComposerService, + "load_agent_app_build_draft", + load_agent_app_build_draft, + ) + monkeypatch.setattr( + roster_controller.AgentComposerService, + "save_agent_app_build_draft", + save_agent_app_build_draft, + ) + monkeypatch.setattr( + roster_controller.AgentComposerService, + "apply_agent_app_build_draft", + apply_agent_app_build_draft, + ) + monkeypatch.setattr( + roster_controller.AgentComposerService, + "discard_agent_app_build_draft", + discard_agent_app_build_draft, + ) + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/publish", + json={"version_note": "publish v1"}, + ): + published = unwrap(AgentPublishApi.post)(AgentPublishApi(), "tenant-1", current_user, agent_id) + assert published["active_config_snapshot_id"] == "version-1" + assert captured["publish"] == { + "tenant_id": "tenant-1", + "agent_id": agent_id, + "account_id": account_id, + "version_note": "publish v1", + } + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft/checkout", + json={"force": True}, + ): + checked_out = unwrap(AgentBuildDraftCheckoutApi.post)( + AgentBuildDraftCheckoutApi(), "tenant-1", current_user, agent_id + ) + assert checked_out["draft"]["id"] == "build-draft-1" + assert captured["checkout"] == { + "tenant_id": "tenant-1", + "agent_id": agent_id, + "account_id": account_id, + "force": True, + } + + with app.test_request_context("/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft"): + loaded = unwrap(AgentBuildDraftApi.get)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id) + assert loaded["draft"]["id"] == "build-draft-1" + assert captured["load"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id} + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft", + json={"variant": "agent_app", "save_strategy": "save_to_current_version", "agent_soul": {}}, + ): + saved = unwrap(AgentBuildDraftApi.put)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id) + assert saved["draft"]["id"] == "build-draft-1" + assert captured["save"]["tenant_id"] == "tenant-1" + assert captured["save"]["agent_id"] == agent_id + assert captured["save"]["account_id"] == account_id + assert captured["save"]["payload"].variant == ComposerVariant.AGENT_APP + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft/apply", + method="POST", + ): + applied = unwrap(AgentBuildDraftApplyApi.post)(AgentBuildDraftApplyApi(), "tenant-1", current_user, agent_id) + assert applied == {"result": "success", "draft": {"id": "draft-1"}} + assert captured["apply"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id} + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft", + method="DELETE", + ): + discarded = unwrap(AgentBuildDraftApi.delete)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id) + assert discarded == {"result": "success"} + assert captured["discard"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id} + + def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py index b8cdf471ca3..7418ca2660a 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py @@ -69,7 +69,7 @@ class TestGenerateSuccess: def test_runtime_session_snapshot_id_is_stable_for_debugger_only(self): assert ( AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.DEBUGGER, snapshot_id="snap-1") - is None + == "snap-1" ) assert ( AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.WEB_APP, snapshot_id="snap-1") @@ -111,7 +111,12 @@ class TestGenerateSuccess: assert result == {"result": "ok"} thread_obj.start.assert_called_once() - generator._resolve_agent.assert_called_once_with(app_model) + generator._resolve_agent.assert_called_once_with( + app_model, + invoke_from=InvokeFrom.WEB_APP, + draft_type=None, + user=user, + ) def test_generate_loads_existing_conversation(self, generator: AgentAppGenerator, mocker: MockerFixture): app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent") diff --git a/api/tests/unit_tests/models/test_agent.py b/api/tests/unit_tests/models/test_agent.py index 422a3218eaa..fb2e834e8ac 100644 --- a/api/tests/unit_tests/models/test_agent.py +++ b/api/tests/unit_tests/models/test_agent.py @@ -7,6 +7,8 @@ from sqlalchemy.exc import IntegrityError from models.agent import ( Agent, + AgentConfigDraft, + AgentConfigDraftType, AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, @@ -34,6 +36,9 @@ def test_agent_enums_match_prd_boundaries(): assert AgentStatus.ARCHIVED.value == "archived" assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION.value == "save_current_version" assert AgentConfigRevisionOperation.RESTORE_VERSION.value == "restore_version" + assert AgentConfigRevisionOperation.PUBLISH_DRAFT.value == "publish_draft" + assert AgentConfigDraftType.DRAFT.value == "draft" + assert AgentConfigDraftType.DEBUG_BUILD.value == "debug_build" assert WorkflowAgentBindingType.ROSTER_AGENT.value == "roster_agent" assert WorkflowAgentBindingType.INLINE_AGENT.value == "inline_agent" @@ -136,6 +141,23 @@ def test_current_snapshot_stores_agent_soul_snapshot_as_long_text_json(): assert version.config_snapshot_dict["env"]["secret_refs"][0]["provider_credential_id"] == "cred-1" +def test_agent_config_draft_stores_editable_agent_soul_as_long_text_json(): + config_snapshot = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "draft prompt"}}) + draft = AgentConfigDraft( + tenant_id="tenant-1", + agent_id="agent-1", + draft_type=AgentConfigDraftType.DRAFT, + draft_owner_key="", + config_snapshot=config_snapshot, + ) + + config_snapshot_column = AgentConfigDraft.__table__.c.config_snapshot + assert isinstance(config_snapshot_column.type, JSONModelColumn) + assert config_snapshot_column.server_default is None + assert draft.config_snapshot_dict == config_snapshot.model_dump(mode="json") + assert draft.config_snapshot_dict["prompt"]["system_prompt"] == "draft prompt" + + def test_workflow_binding_stores_node_job_config_separately_from_agent_soul(): node_job_config = { "schema_version": 1, @@ -166,6 +188,7 @@ def test_long_text_columns_do_not_use_mysql_incompatible_server_defaults(): assert isinstance(column.type, LongText) assert column.server_default is None assert AgentConfigSnapshot.__table__.c.config_snapshot.server_default is None + assert AgentConfigDraft.__table__.c.config_snapshot.server_default is None assert WorkflowAgentNodeBinding.__table__.c.node_job_config.server_default is None diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index 0dfa00e205a..e6d59f660ab 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -8,6 +8,8 @@ from sqlalchemy.exc import IntegrityError from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError from models.agent import ( Agent, + AgentConfigDraft, + AgentConfigDraftType, AgentConfigRevisionOperation, AgentConfigSnapshot, AgentDebugConversation, @@ -271,11 +273,11 @@ def test_publish_save_strategies_run_publish_validation(strategy: ComposerSaveSt def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession(scalar=[None]) - created_version = SimpleNamespace(id="version-1") + saved_draft = SimpleNamespace(id="draft-1", config_snapshot_dict={"prompt": {"system_prompt": "x"}}) monkeypatch.setattr(composer_service.db, "session", fake_session) monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None) - monkeypatch.setattr(AgentComposerService, "_create_config_version", lambda **kwargs: created_version) + monkeypatch.setattr(AgentComposerService, "_save_agent_draft", lambda **kwargs: saved_draft) monkeypatch.setattr(AgentComposerService, "load_agent_app_composer", lambda **kwargs: {"loaded": True}) payload = ComposerSavePayload.model_validate( { @@ -293,23 +295,21 @@ def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch: pytest. assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []} assert result == {"loaded": True} assert fake_session.added[0].name == "Analyst" - assert fake_session.added[0].active_config_snapshot_id == "version-1" - assert fake_session.added[0].active_config_has_model is False + assert fake_session.added[0].active_config_snapshot_id is None assert fake_session.commits == 1 -def test_save_agent_app_composer_updates_current_version(monkeypatch: pytest.MonkeyPatch): +def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.MonkeyPatch): agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1", updated_by=None) fake_session = FakeSession(scalar=[agent]) - updated = {} + saved = {} monkeypatch.setattr(composer_service.db, "session", fake_session) monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None) - monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: SimpleNamespace(id="version-1")) monkeypatch.setattr( AgentComposerService, - "_update_current_version", - lambda **kwargs: updated.update(kwargs) or SimpleNamespace(id="version-2"), + "_save_agent_draft", + lambda **kwargs: saved.update(kwargs) or SimpleNamespace(id="draft-1"), ) monkeypatch.setattr(AgentComposerService, "load_agent_app_composer", lambda **kwargs: {"loaded": True}) payload = ComposerSavePayload.model_validate( @@ -326,12 +326,118 @@ def test_save_agent_app_composer_updates_current_version(monkeypatch: pytest.Mon assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []} assert result == {"loaded": True} - assert updated["operation"].value == "save_current_version" - assert agent.active_config_has_model is True + assert saved["draft_type"] == AgentConfigDraftType.DRAFT + assert saved["agent_soul"].model_dump(mode="json") == _agent_soul_with_model().model_dump(mode="json") assert fake_session._scalar == [] assert fake_session.commits == 1 +def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest.MonkeyPatch): + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Iris", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="version-1", + ) + draft = AgentConfigDraft( + tenant_id="tenant-1", + agent_id="agent-1", + draft_type=AgentConfigDraftType.DRAFT, + draft_owner_key="", + base_snapshot_id="version-1", + config_snapshot=_agent_soul_with_model(), + ) + version = SimpleNamespace(id="version-2") + fake_session = FakeSession(scalar=[agent, draft]) + created: dict[str, object] = {} + + monkeypatch.setattr(composer_service.db, "session", fake_session) + monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda payload: None) + monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", lambda **kwargs: None) + monkeypatch.setattr( + AgentComposerService, + "_create_config_version", + lambda **kwargs: created.update(kwargs) or version, + ) + monkeypatch.setattr(AgentComposerService, "_serialize_version", lambda _version: {"id": _version.id}) + + result = AgentComposerService.publish_agent_app_draft( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + version_note="ship it", + ) + + assert result["result"] == "success" + assert result["active_config_snapshot_id"] == "version-2" + assert result["draft"]["base_snapshot_id"] == "version-2" + assert created["operation"] == AgentConfigRevisionOperation.PUBLISH_DRAFT + assert created["previous_snapshot_id"] == "version-1" + assert agent.active_config_snapshot_id == "version-2" + assert agent.active_config_has_model is True + assert fake_session.commits == 1 + + +def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(monkeypatch: pytest.MonkeyPatch): + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Iris", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="version-1", + ) + normal_draft = AgentConfigDraft( + tenant_id="tenant-1", + agent_id="agent-1", + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", + base_snapshot_id="version-1", + config_snapshot=_agent_soul_with_model(), + ) + fake_session = FakeSession(scalar=[agent, normal_draft, None]) + + monkeypatch.setattr(composer_service.db, "session", fake_session) + + checked_out = AgentComposerService.checkout_agent_app_build_draft( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + ) + + build_draft = fake_session.added[0] + assert checked_out["draft"]["id"] == build_draft.id + assert checked_out["draft"]["draft_type"] == AgentConfigDraftType.DEBUG_BUILD.value + assert checked_out["draft"]["account_id"] == "account-1" + assert checked_out["draft"]["base_snapshot_id"] == "version-1" + assert checked_out["agent_soul"] == normal_draft.config_snapshot_dict + assert fake_session.commits == 1 + + fake_session = FakeSession(scalar=[agent, build_draft, normal_draft]) + monkeypatch.setattr(composer_service.db, "session", fake_session) + + applied = AgentComposerService.apply_agent_app_build_draft( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + ) + + assert applied["result"] == "success" + assert applied["draft"]["id"] == normal_draft.id + assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict + assert fake_session.deleted == [build_draft] + assert fake_session.commits == 1 + + def test_agent_app_composer_candidates_and_impact(monkeypatch: pytest.MonkeyPatch): bindings = [ SimpleNamespace(app_id="app-1", workflow_id="workflow-1", node_id="node-1"), @@ -1603,7 +1709,17 @@ def test_active_config_is_published_flags_handle_matching_and_empty_snapshots(): status=AgentStatus.ACTIVE, active_config_snapshot_id=None, ) - service = AgentRosterService(FakeSession(scalars=[["agent-1"], ["agent-1"]])) + published_draft = AgentConfigDraft( + tenant_id="tenant-1", + agent_id="agent-1", + draft_type=AgentConfigDraftType.DRAFT, + draft_owner_key="", + base_snapshot_id="version-1", + config_snapshot=AgentSoulConfig(), + ) + service = AgentRosterService( + FakeSession(scalars=[["agent-1"], [published_draft], ["agent-1"], [published_draft]]) + ) flags = service.load_active_config_is_published_by_agent_id(tenant_id="tenant-1", agents=[agent, draft_agent]) @@ -1937,6 +2053,7 @@ def test_agent_app_visible_versions_exclude_draft_saves(): roster_operations = AgentRosterService._visible_version_operations(roster_agent) assert agent_app_operations == { + AgentConfigRevisionOperation.PUBLISH_DRAFT, AgentConfigRevisionOperation.SAVE_NEW_VERSION, AgentConfigRevisionOperation.SAVE_TO_ROSTER, AgentConfigRevisionOperation.RESTORE_VERSION, @@ -1948,7 +2065,7 @@ def test_agent_app_visible_versions_exclude_draft_saves(): def test_restore_roster_agent_version_switches_active_snapshot(monkeypatch: pytest.MonkeyPatch): - fake_session = FakeSession(scalar=["version-2", 6]) + fake_session = FakeSession(scalar=["version-2", None]) service = AgentRosterService(fake_session) agent = Agent( id="agent-1", @@ -1979,19 +2096,22 @@ def test_restore_roster_agent_version_switches_active_snapshot(monkeypatch: pyte account_id="account-1", ) - assert restored == {"result": "success", "active_config_snapshot_id": "version-2"} - assert agent.active_config_snapshot_id == "version-2" - assert agent.active_config_has_model is True + assert restored == { + "result": "success", + "active_config_snapshot_id": "version-4", + "draft_config_id": fake_session.added[0].id, + "restored_version_id": "version-2", + } + assert agent.active_config_snapshot_id == "version-4" assert agent.updated_by == "account-1" assert fake_session.commits == 1 - revision = fake_session.added[0] - assert revision.tenant_id == "tenant-1" - assert revision.agent_id == "agent-1" - assert revision.previous_snapshot_id == "version-4" - assert revision.current_snapshot_id == "version-2" - assert revision.revision == 7 - assert revision.operation == AgentConfigRevisionOperation.RESTORE_VERSION - assert revision.created_by == "account-1" + draft = fake_session.added[0] + assert draft.tenant_id == "tenant-1" + assert draft.agent_id == "agent-1" + assert draft.draft_type == AgentConfigDraftType.DRAFT + assert draft.base_snapshot_id == "version-2" + assert draft.config_snapshot_dict == _agent_soul_with_model().model_dump(mode="json") + assert draft.updated_by == "account-1" def test_restore_roster_agent_version_rejects_invisible_versions(monkeypatch: pytest.MonkeyPatch):