Aari 0d4d0cb17d
feat(agents): database-backed storage for custom agent definitions (#4359)
* feat(agents): database-backed storage for custom agent definitions

Add an agent_storage.backend switch (default file, behaviour-unchanged) with a
db backend that stores each custom agent as a row in the shared SQL persistence
layer, so a multi-instance deployment sees the same agents on every node
(#4331, #4357). Introduces an AgentStore interface routing all read/write
surfaces, an agents table + migration 0006, startup validation, and a file->db
importer. Follows the thread_meta store / run_events backend-switch /
0003_scheduled_tasks migration patterns; no new dependency.

* fix(agents): make db storage path production-ready (review round 1)

Addresses review feedback on the db/sync agent-storage path:

- sql.py: mirror the async engine's per-connection SQLite PRAGMAs on the sync
  engine (busy_timeout=30000, synchronous=NORMAL, foreign_keys=ON, WAL) so both
  engines behave identically against the shared DB; guard the engine cache with
  a lock (double-checked) so concurrent first-touch cannot build duplicate
  engines or register the connect listener twice.
- routers/agents.py + routers/assistants_compat.py: offload the sync-store reads
  that ran on the event loop (list/get/check, update's pre-read + legacy guard +
  refresh, and assistants_compat's four list routes) via asyncio.to_thread — on
  db+postgres each was a network round trip stalling the loop. Writes were
  already offloaded.
- file.py: translate the create() mkdir(exist_ok=False) race FileExistsError
  into AgentExistsError (router 409, matching SqlAgentStore's IntegrityError
  path); correct the _write docstring — per-file atomic replace, two commits
  sequential not transactional.

Tests: sync-engine PRAGMA + engine-cache reuse assertions; file create-race ->
AgentExistsError; strict Blockbuster anchor over the read endpoints so a
regression back onto the loop fails CI.

* fix(agents): address round-2 review on the db store path

- update_agent tool: align the docstring/inline comment with FileAgentStore._write.
  Cross-field write atomicity is db-only; the file backend commits config then
  soul via two sequential os.replace (a crash between them can leave a fresh
  config.yaml beside a stale SOUL.md). The dropped partial-write *reporting* is
  an intentional tradeoff — the stage-then-replace safety is preserved
  (test_update_agent_soul_failure_does_not_replace_config still holds).
- SqlAgentStore.update(): true upsert. Catch IntegrityError on the
  insert-on-missing branch, re-fetch and apply, so two concurrent first-time
  writes (e.g. two setup_agent handshakes) converge instead of surfacing a raw
  UNIQUE(user_id, name) violation as a 500. Symmetric with create().
- get_agent_store(): document the graph-subprocess config-resolution invariant
  (the except->file fallback is a genuine no-config path, not a mask for a
  misconfigured graph process) and pin it with two tests driving the real
  get_app_config() file resolution: db resolves from an on-disk config.yaml,
  file fallback when config is unresolvable.

* test(agents): cover SqlAgentStore.update() write-race upsert recovery

Mandatory-TDD test for the round-2 fix in 0680340a: two concurrent first-time
update()s where the loser's insert hits UNIQUE(user_id, name). Deterministically
forces the IntegrityError recovery path by making the first _row probe miss the
committed winner, and asserts last-writer-wins instead of a surfaced 500.
2026-07-23 08:03:21 +08:00

222 lines
9.9 KiB
Python

"""SQL-backed agent store (synchronous).
Serves the ``agent_storage.backend: db`` path. It is intentionally synchronous
and uses its own small engine (see :mod:`deerflow.persistence.agents.base` for
why the store is sync). The engine points at the same database the async
persistence layer manages — the ``agents`` table is created by that layer's
Alembic bootstrap (migration ``0006``); this store only reads and writes rows.
Both the sqlite (stdlib) and postgres (psycopg) sync drivers already ship with
the app, so this adds no dependency.
"""
from __future__ import annotations
import logging
import shutil
import threading
import uuid
from collections.abc import Hashable
from datetime import UTC, datetime
from sqlalchemy import Engine, create_engine, delete, event, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker
from deerflow.config.agents_config import AgentConfig
from deerflow.config.paths import get_paths
from deerflow.persistence.agents.base import (
AgentDeleteOutcome,
AgentExistsError,
AgentStore,
parse_agent_config,
)
from deerflow.persistence.agents.model import AgentRow
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.time import coerce_iso
logger = logging.getLogger(__name__)
# Cache sync engines by URL: the store is constructed on demand in multiple
# places (gateway routes, the graph factory) and each process should reuse one
# engine/pool rather than opening a connection per call. The lock keeps two
# threads first-touching the same URL from building — and registering connect
# listeners on — duplicate engines.
_engines: dict[str, Engine] = {}
_engines_lock = threading.Lock()
def _build_engine(url: str) -> Engine:
connect_args = {"check_same_thread": False} if url.startswith("sqlite") else {}
engine = create_engine(url, future=True, pool_pre_ping=True, connect_args=connect_args)
if url.startswith("sqlite"):
# Mirror the async engine's per-connection PRAGMAs (persistence/engine.py).
# journal_mode=WAL is persistent on the DB file (the async bootstrap sets
# it), but synchronous and busy_timeout are per-connection: without this
# these sync connections run synchronous=FULL and pysqlite's default 5s
# busy_timeout rather than the async engine's NORMAL + 30s. Match them so
# both engines behave identically against the shared DB and a concurrent
# writer waits up to 30s instead of failing early on lock contention.
@event.listens_for(engine, "connect")
def _enable_sqlite_pragmas(dbapi_conn, _record): # noqa: ARG001 — SQLAlchemy contract
cursor = dbapi_conn.cursor()
try:
cursor.execute("PRAGMA journal_mode=WAL;")
cursor.execute("PRAGMA synchronous=NORMAL;")
cursor.execute("PRAGMA foreign_keys=ON;")
cursor.execute("PRAGMA busy_timeout=30000;")
finally:
cursor.close()
return engine
def _get_sessionmaker(url: str) -> sessionmaker[Session]:
engine = _engines.get(url)
if engine is None:
with _engines_lock:
engine = _engines.get(url)
if engine is None:
engine = _build_engine(url)
_engines[url] = engine
return sessionmaker(engine, expire_on_commit=False)
def _config_document(config: dict) -> dict:
"""Strip the natural key from the stored document (``name`` is its own column)."""
return {k: v for k, v in config.items() if k != "name"}
class SqlAgentStore(AgentStore):
def __init__(self, url: str) -> None:
self._Session = _get_sessionmaker(url)
def _row(self, session: Session, name: str, user_id: str) -> AgentRow | None:
stmt = select(AgentRow).where(AgentRow.user_id == user_id, AgentRow.name == name.lower())
return session.execute(stmt).scalar_one_or_none()
def get(self, name: str, *, user_id: str | None = None) -> AgentConfig:
effective_user = user_id or get_effective_user_id()
with self._Session() as session:
row = self._row(session, name, effective_user)
if row is None:
raise FileNotFoundError(f"Agent config not found: {name} (user {effective_user})")
return parse_agent_config(row.config or {}, row.name)
def exists(self, name: str, *, user_id: str | None = None) -> bool:
effective_user = user_id or get_effective_user_id()
with self._Session() as session:
return self._row(session, name, effective_user) is not None
def get_soul(self, name: str, *, user_id: str | None = None) -> str | None:
effective_user = user_id or get_effective_user_id()
with self._Session() as session:
row = self._row(session, name, effective_user)
if row is None:
return None
return row.soul or None
def list(self, *, user_id: str | None = None) -> list[AgentConfig]:
effective_user = user_id or get_effective_user_id()
stmt = select(AgentRow).where(AgentRow.user_id == effective_user).order_by(AgentRow.name.asc())
with self._Session() as session:
rows = list(session.execute(stmt).scalars())
return [parse_agent_config(r.config or {}, r.name) for r in rows]
def list_all(self) -> list[tuple[str, AgentConfig]]:
stmt = select(AgentRow).order_by(AgentRow.user_id.asc(), AgentRow.name.asc())
with self._Session() as session:
rows = list(session.execute(stmt).scalars())
return [(r.user_id, parse_agent_config(r.config or {}, r.name)) for r in rows]
def create(self, name: str, config: dict, soul: str, *, user_id: str | None = None) -> None:
effective_user = user_id or get_effective_user_id()
now = datetime.now(UTC)
row = AgentRow(
id=uuid.uuid4().hex,
user_id=effective_user,
name=name.lower(),
config=_config_document(config),
soul=soul or "",
created_at=now,
updated_at=now,
)
try:
with self._Session() as session:
session.add(row)
session.commit()
except IntegrityError as e:
# UNIQUE(user_id, name) turns the check-then-write race into a clean conflict.
raise AgentExistsError(f"Agent '{name}' already exists for user '{effective_user}'") from e
def update(self, name: str, config: dict | None, soul: str | None, *, user_id: str | None = None) -> None:
effective_user = user_id or get_effective_user_id()
with self._Session() as session:
row = self._row(session, name, effective_user)
if row is not None:
self._apply_update(row, config, soul)
session.commit()
return
# Upsert: setup_agent and any first-time write land here. Two
# concurrent first-time updates (e.g. two setup_agent handshakes) can
# both see row is None and both insert; UNIQUE(user_id, name) rejects
# the loser. Re-fetch the winner's row and apply the update to it
# rather than letting a raw IntegrityError surface as a 500 — a true
# upsert, symmetric with create()'s conflict handling.
row = AgentRow(
id=uuid.uuid4().hex,
user_id=effective_user,
name=name.lower(),
config=_config_document(config or {}),
soul=soul or "",
)
session.add(row)
try:
session.commit()
except IntegrityError:
session.rollback()
existing = self._row(session, name, effective_user)
if existing is None:
raise
self._apply_update(existing, config, soul)
session.commit()
@staticmethod
def _apply_update(row: AgentRow, config: dict | None, soul: str | None) -> None:
if config is not None:
row.config = _config_document(config)
if soul is not None:
row.soul = soul
def delete(self, name: str, *, user_id: str | None = None) -> AgentDeleteOutcome:
effective_user = user_id or get_effective_user_id()
with self._Session() as session:
result = session.execute(delete(AgentRow).where(AgentRow.user_id == effective_user, AgentRow.name == name.lower()))
session.commit()
row_deleted = result.rowcount > 0
agent_dir = get_paths().user_agent_dir(effective_user, name)
if row_deleted:
# The agent existed as a row; remove any co-located on-disk memory
# (deermem file backend) so it is not orphaned. Mirrors the file
# backend's rmtree, which bundles config + soul + memory.
if agent_dir.exists():
shutil.rmtree(agent_dir)
return "deleted"
# No agent row. A bare on-disk directory here holds only memory/facts
# data (in db mode the config lives in the row, not on disk), so preserve
# it rather than deleting a user's memory (#4279) — do not rmtree it.
if agent_dir.exists():
return "not-custom-agent"
return "missing"
def signature(self) -> Hashable:
# MAX(updated_at) is not covered by an index (only user_id and the
# (user_id, name) unique constraint are), so this is a small full scan.
# It runs only on the webhook registry's cache-freshness check against a
# tiny agents table, so an index is not warranted; revisit if agents ever
# grow into the thousands with frequent webhook deliveries.
with self._Session() as session:
max_updated, count = session.execute(select(func.max(AgentRow.updated_at), func.count(AgentRow.id))).one()
token = coerce_iso(max_updated) if isinstance(max_updated, datetime) else str(max_updated)
return (token, int(count))