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

260 lines
12 KiB
Python

"""Filesystem-backed agent store — today's per-user layout, behaviour-preserving.
The read methods are the pre-refactor bodies of ``load_agent_config`` /
``load_agent_soul`` / ``list_custom_agents`` (so the free functions in
:mod:`deerflow.config.agents_config` dispatch here without changing behaviour).
Writes use a staged temp-file + atomic ``os.replace`` commit — the crash-safety
the ``update_agent`` tool already had, applied uniformly to create/update.
Path/user resolution is done through the :mod:`deerflow.config.agents_config`
module object (``_ac.get_paths`` / ``_ac.get_effective_user_id``) rather than
direct imports, so it honours the same monkeypatch seams the existing agent
tests target.
"""
from __future__ import annotations
import logging
import shutil
import tempfile
from collections.abc import Hashable
from pathlib import Path
from typing import Any
import yaml
from deerflow.config import agents_config as _ac
from deerflow.config.agents_config import (
SOUL_FILENAME,
AgentConfig,
resolve_agent_dir,
validate_agent_name,
)
from deerflow.persistence.agents.base import (
AgentDeleteOutcome,
AgentExistsError,
AgentStore,
parse_agent_config,
)
from deerflow.runtime.user_context import DEFAULT_USER_ID
logger = logging.getLogger(__name__)
class FileAgentStore(AgentStore):
def get(self, name: str, *, user_id: str | None = None) -> AgentConfig:
name = validate_agent_name(name)
agent_dir = resolve_agent_dir(name, user_id=user_id)
config_file = agent_dir / "config.yaml"
if not agent_dir.exists():
raise FileNotFoundError(f"Agent directory not found: {agent_dir}")
if not config_file.exists():
raise FileNotFoundError(f"Agent config not found: {config_file}")
try:
with open(config_file, encoding="utf-8") as f:
data: dict[str, Any] = yaml.safe_load(f) or {}
except yaml.YAMLError as e:
raise ValueError(f"Failed to parse agent config {config_file}: {e}") from e
return parse_agent_config(data, name)
def exists(self, name: str, *, user_id: str | None = None) -> bool:
name = validate_agent_name(name)
paths = _ac.get_paths()
effective_user = user_id or _ac.get_effective_user_id()
return paths.user_agent_dir(effective_user, name).exists() or paths.agent_dir(name).exists()
def get_soul(self, name: str, *, user_id: str | None = None) -> str | None:
agent_dir = resolve_agent_dir(name, user_id=user_id)
soul_path = agent_dir / SOUL_FILENAME
# resolve_agent_dir requires config.yaml; SOUL.md loading does not, so
# when the resolver fell back to its default path (no qualifying dir),
# check the per-user and legacy dirs directly (#4135). The config.yaml
# guard keeps this fallback from firing for a properly-resolved per-user
# agent that merely lacks SOUL.md (preserves per-user-shadows-legacy).
if not soul_path.exists() and not (agent_dir / "config.yaml").exists():
paths = _ac.get_paths()
effective_user = user_id or _ac.get_effective_user_id()
for candidate in (
paths.user_agent_dir(effective_user, name),
paths.agent_dir(name),
):
if (candidate / SOUL_FILENAME).exists():
soul_path = candidate / SOUL_FILENAME
break
if not soul_path.exists():
return None
content = soul_path.read_text(encoding="utf-8").strip()
return content or None
def list(self, *, user_id: str | None = None) -> list[AgentConfig]:
paths = _ac.get_paths()
effective_user = user_id or _ac.get_effective_user_id()
seen: set[str] = set()
agents: list[AgentConfig] = []
for root in (paths.user_agents_dir(effective_user), paths.agents_dir):
if not root.exists():
continue
for entry in sorted(root.iterdir()):
if not entry.is_dir() or entry.name in seen:
continue
if not (entry / "config.yaml").exists():
logger.debug("Skipping %s: no config.yaml", entry.name)
continue
try:
agents.append(self.get(entry.name, user_id=effective_user))
seen.add(entry.name)
except Exception as e: # noqa: BLE001 — one bad agent must not hide the rest
logger.warning("Skipping agent '%s': %s", entry.name, e)
agents.sort(key=lambda a: a.name)
return agents
def list_all(self) -> list[tuple[str, AgentConfig]]:
result: list[tuple[str, AgentConfig]] = []
for user_id, name in self._discover():
try:
result.append((user_id, self.get(name, user_id=user_id)))
except Exception as e: # noqa: BLE001
logger.warning("list_all: skipping agent %s/%s: %s", user_id, name, e)
return result
def create(self, name: str, config: dict, soul: str, *, user_id: str | None = None) -> None:
name = validate_agent_name(name)
paths = _ac.get_paths()
effective_user = user_id or _ac.get_effective_user_id()
agent_dir = paths.user_agent_dir(effective_user, name)
# Refuse if a per-user directory OR a legacy shared directory already
# owns the name — the agents router's 409 semantics (a legacy agent must
# not be shadowed; a per-user dir may be memory-only but still blocks).
if agent_dir.exists() or paths.agent_dir(name).exists():
raise AgentExistsError(f"Agent '{name}' already exists for user '{effective_user}'")
try:
agent_dir.mkdir(parents=True, exist_ok=False)
except FileExistsError as e:
# A concurrent create passed the existence check above and reached
# mkdir first. Surface the router's 409 (via AgentExistsError) rather
# than a generic 500 — mirrors SqlAgentStore's IntegrityError path.
raise AgentExistsError(f"Agent '{name}' already exists for user '{effective_user}'") from e
try:
self._write(agent_dir, config, soul)
except Exception:
# The directory was newly created for this call; a failed write
# must not leave an empty/partial agent dir behind.
shutil.rmtree(agent_dir, ignore_errors=True)
raise
def update(self, name: str, config: dict | None, soul: str | None, *, user_id: str | None = None) -> None:
name = validate_agent_name(name)
effective_user = user_id or _ac.get_effective_user_id()
agent_dir = _ac.get_paths().user_agent_dir(effective_user, name)
pre_existing = agent_dir.exists()
agent_dir.mkdir(parents=True, exist_ok=True)
try:
self._write(agent_dir, config, soul)
except Exception:
# Only clean up a directory this call created — never delete a
# pre-existing agent on a failed write.
if not pre_existing:
shutil.rmtree(agent_dir, ignore_errors=True)
raise
def delete(self, name: str, *, user_id: str | None = None) -> AgentDeleteOutcome:
name = validate_agent_name(name)
paths = _ac.get_paths()
effective_user = user_id or _ac.get_effective_user_id()
agent_dir = paths.user_agent_dir(effective_user, name)
if not agent_dir.exists():
# A legacy shared-layout agent is intentionally left in place (the
# write path never targets it); report it distinctly.
return "legacy" if paths.agent_dir(name).exists() else "missing"
if not (agent_dir / "config.yaml").is_file():
# The directory holds memory/facts data but is not a custom agent
# (no config.yaml) — preserve it rather than deleting a user's memory
# (#4279). rmtree below would otherwise take the whole tree.
return "not-custom-agent"
# rmtree removes config.yaml, SOUL.md and the co-located memory.json in
# one shot — the historical behaviour.
shutil.rmtree(agent_dir)
return "deleted"
def signature(self) -> Hashable:
sig: list[tuple[str, str, float]] = []
for user_id, name in self._discover():
config = resolve_agent_dir(name, user_id=user_id) / "config.yaml"
try:
sig.append((user_id, name, config.stat().st_mtime))
except OSError:
continue
return tuple(sig)
# -- internals --
def _discover(self) -> list[tuple[str, str]]:
"""Enumerate ``(user_id, name)`` across per-user and legacy layouts.
A legacy shared-layout agent is attributed to ``DEFAULT_USER_ID`` and is
shadowed only by a ``users/default/`` agent of the same name — not by an
agent another user happens to own — matching the GitHub registry's
historical discovery (``load_agent_config(name)`` resolves a legacy agent
under ``DEFAULT_USER_ID``).
"""
paths = _ac.get_paths()
discovered: list[tuple[str, str]] = []
seen: set[tuple[str, str]] = set()
users_root = paths.base_dir / "users"
if users_root.exists():
for user_entry in sorted(users_root.iterdir()):
if not user_entry.is_dir():
continue
agents_root = paths.user_agents_dir(user_entry.name)
if not agents_root.exists():
continue
for entry in sorted(agents_root.iterdir()):
if entry.is_dir() and (entry / "config.yaml").exists():
key = (user_entry.name, entry.name)
discovered.append(key)
seen.add(key)
legacy_root = paths.agents_dir
if legacy_root.exists():
for entry in sorted(legacy_root.iterdir()):
if entry.is_dir() and (entry / "config.yaml").exists() and (DEFAULT_USER_ID, entry.name) not in seen:
discovered.append((DEFAULT_USER_ID, entry.name))
return discovered
@staticmethod
def _write(agent_dir: Path, config: dict | None, soul: str | None) -> None:
"""Write config.yaml and/or SOUL.md, each via an atomic ``os.replace``.
Each part is written only when supplied (``config``/``soul`` non-None),
staged to a temp file then committed with ``os.replace``, so neither file
is ever observed half-written. The two commits are sequential, **not** a
single transaction: a crash between them can leave a freshly-replaced
config.yaml beside a stale SOUL.md (single-node, sub-millisecond window).
The ``db`` backend commits both fields in one transaction; if cross-file
atomicity ever matters here, restore ``update_agent``'s partial-write
reporting.
"""
pending: list[tuple[Path, Path]] = []
staged: list[Path] = []
try:
if config is not None:
config_text = yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False)
config_tmp = _stage_temp(agent_dir / "config.yaml", config_text)
staged.append(config_tmp)
pending.append((config_tmp, agent_dir / "config.yaml"))
if soul is not None:
soul_tmp = _stage_temp(agent_dir / SOUL_FILENAME, soul)
staged.append(soul_tmp)
pending.append((soul_tmp, agent_dir / SOUL_FILENAME))
for tmp, target in pending:
tmp.replace(target)
staged.remove(tmp)
finally:
for tmp in staged:
tmp.unlink(missing_ok=True)
def _stage_temp(target: Path, text: str) -> Path:
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=target.parent, suffix=".tmp", delete=False) as f:
f.write(text)
return Path(f.name)