feat(agent-v2): add agent draft build lifecycle (#37887)

This commit is contained in:
zyssyz123 2026-06-24 21:21:00 +08:00 committed by GitHub
parent 611a7c5081
commit 2382a49616
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1183 additions and 137 deletions

View File

@ -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/<uuid:agent_id>/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/<uuid:agent_id>/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/<uuid:agent_id>/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/<uuid:agent_id>/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/<uuid:agent_id>/copy")
class AgentAppCopyApi(Resource):
@console_ns.expect(console_ns.models[AgentAppCopyPayload.__name__])

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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")

View File

@ -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",

View File

@ -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.

View File

@ -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(

View File

@ -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)

View File

@ -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/<uuid:agent_id>/composer/candidates",
"/agent/<uuid:agent_id>/features",
"/agent/<uuid:agent_id>/copy",
"/agent/<uuid:agent_id>/publish",
"/agent/<uuid:agent_id>/build-draft/checkout",
"/agent/<uuid:agent_id>/build-draft",
"/agent/<uuid:agent_id>/build-draft/apply",
"/agent/<uuid:agent_id>/referencing-workflows",
"/agent/<uuid:agent_id>/drive/files",
"/agent/<uuid:agent_id>/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:

View File

@ -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")

View File

@ -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

View File

@ -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):