"""ORM model for custom agents.""" from __future__ import annotations from datetime import UTC, datetime from sqlalchemy import BigInteger, Boolean, Index, Integer, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from deerflow.persistence.base import Base from deerflow.persistence.types import BeijingDateTime, PortableJSON class AgentRow(Base): __tablename__ = "agents" # Stored as a plain string so the same id can be used as the filesystem # directory name under {base_dir}/agents/{id}/. id: Mapped[str] = mapped_column(String(64), primary_key=True) name: Mapped[str] = mapped_column(String(191), nullable=False, index=True) description: Mapped[str] = mapped_column(String(1024), nullable=False, default="") user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) # When True, the agent is moved into the password-gated "private square" # (私密广场) and is hidden from the regular public square / built-in list. # The flag is independent of ``published``: a private agent is still # visible to its owner in the "我的" tab, and visible to anyone in the # private-square listing once the password has been verified. is_private: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) # Publish square (发布广场) this agent is filed under. A pure organizational # category with no access control — independent of ``published`` / # ``is_private``. Empty string = "not assigned" → the listing layer treats # it as the admin-configured default square. Each agent belongs to at most # one square (single column), so it can never be published to two squares. square_id: Mapped[str] = mapped_column(String(64), nullable=False, default="", server_default="", index=True) # NULL = not featured on the homepage selector. Non-NULL = admin-curated # featured rank (lower sorts first). Only ``published=True`` agents are # eligible; the router enforces that invariant on writes. BIGINT so the # frontend can seed initial ranks from ``Date.now()`` (ms-resolution # timestamps overflow a 4-byte INT on MySQL). featured_order: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True) created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)) updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) class AgentFavoriteRow(Base): """A user's bookmark of a (typically public-square) agent. Lets a user collect agents from the 广场 (square) into their "我的" tab without owning them. One row per (user, agent); the unique constraint makes favoriting idempotent. """ __tablename__ = "agent_favorites" __table_args__ = (UniqueConstraint("user_id", "agent_id", name="uq_agent_favorites_user_agent"),) id: Mapped[str] = mapped_column(String(64), primary_key=True) user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) # Where this favorite came from: ``"user"`` = the user bookmarked it # themselves; ``"position"`` = it was auto-granted by the user's 岗位 # (position) via the subscription sync engine. Sync only ever adds/removes # ``"position"`` rows, so a user's own bookmarks are never clobbered. origin: Mapped[str] = mapped_column(String(16), nullable=False, default="user", server_default="user") # The position that granted this favorite (only set when ``origin='position'``). position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)) class AgentPinRow(Base): """A per-user pin that keeps an agent near the top of that user's lists.""" __tablename__ = "agent_pins" __table_args__ = (UniqueConstraint("user_id", "agent_id", name="uq_agent_pins_user_agent"),) id: Mapped[str] = mapped_column(String(64), primary_key=True) user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)) class AgentSkillRow(Base): """Denormalized cache of a custom agent's selected skills. ``config.yaml`` under ``.deer-flow/agents/{id}/`` stays the source of truth for the agent runtime (the lead-agent prompt reads it directly). This table is a read cache so the gallery list endpoint can render skill badges with a single batched ``IN`` query instead of stat+YAML-parsing every agent's ``config.yaml`` on each request. Written through on create/update and backfilled from disk by ``_sync_legacy_agents``. One row per (agent, skill); ``position`` preserves the configured order. """ __tablename__ = "agent_skills" # Prefix-unique instead of UniqueConstraint: (64 + 120) * 4 = 736 bytes # fits MySQL's legacy 767-byte index limit (utf8mb4); a plain unique over # agent_id + skill_name(191) would be 1020 bytes and fail create_all with # error 1071 there. mysql_length is ignored on SQLite/Postgres. __table_args__ = (Index("uq_agent_skills_agent_skill", "agent_id", "skill_name", unique=True, mysql_length={"skill_name": 120}),) id: Mapped[str] = mapped_column(String(64), primary_key=True) agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) skill_name: Mapped[str] = mapped_column(String(191), nullable=False, index=True) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) class AgentExtrasRow(Base): """Denormalized cache of a custom agent's ``tool_groups`` / ``model``. Companion to :class:`AgentSkillRow` — same rationale (avoid per-agent ``config.yaml`` reads in the list path). One row per agent. The presence of a row also marks "this agent has been mirrored": the list path treats a missing row as "unknown / not yet backfilled" (``skills=None``) so it can be told apart from an explicitly empty skill list. ``skills_synced_at`` records when the cache was last written from disk so the backfill can skip agents whose ``config.yaml`` has not changed. """ __tablename__ = "agent_extras" agent_id: Mapped[str] = mapped_column(String(64), primary_key=True) # list[str] | None — the agent's tool_groups whitelist from config.yaml. tool_groups: Mapped[list | None] = mapped_column(PortableJSON(), nullable=True, default=None) model: Mapped[str | None] = mapped_column(String(191), nullable=True, default=None) skills_synced_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))