mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-08 03:30:34 +00:00
* fix(agents): store an explicit null description as empty
PUT /api/agents/{name} accepted "description": null and wrote it into the
agent's config, but AgentConfig.description is a str. Every later load of
that agent failed validation: the PUT itself, GET and repair PUTs returned
500, the file backend dropped the agent from the list, and the db backend
failed the owner's whole list.
Store an explicit null as the empty default, and have parse_agent_config
read a stored null description (already written by this route, or a bare
YAML key) as empty so affected agents load again.
* docs(changelog): reference #7638 for the agent null description fix
* docs(agents): document that a null description resets it to empty
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
151 lines
6.6 KiB
Python
151 lines
6.6 KiB
Python
"""Abstract interface for custom agent definition storage.
|
|
|
|
Two implementations:
|
|
- :class:`FileAgentStore` — the historical per-user on-disk layout
|
|
(``config.yaml`` + ``SOUL.md``), still the default and behaviourally
|
|
unchanged.
|
|
- :class:`SqlAgentStore` — a row per agent in the shared SQL persistence
|
|
layer, so every node in a multi-instance deployment sees the same agents.
|
|
|
|
The store is deliberately **synchronous**. Its consumers — the LangGraph graph
|
|
factory (``make_lead_agent``), the ``setup_agent`` / ``update_agent`` tools, and
|
|
the GitHub agent registry — are synchronous and may run on the event loop or in
|
|
a separate process from the gateway, where an async engine cannot be driven.
|
|
Async HTTP routes call the store via ``asyncio.to_thread`` (the same pattern the
|
|
agents router already uses for filesystem work).
|
|
|
|
``user_id`` semantics (kept identical to the pre-refactor free functions in
|
|
:mod:`deerflow.config.agents_config`, which is what makes the file backend a
|
|
behaviour-neutral move): ``None`` resolves to the effective user from the
|
|
request context — ``"default"`` in no-auth mode — via
|
|
:func:`deerflow.runtime.user_context.get_effective_user_id`. This is filesystem
|
|
bucket semantics, distinct from the AUTO/None sentinel used by the async
|
|
``thread_meta`` repositories.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import abc
|
|
import logging
|
|
from collections.abc import Hashable
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import ValidationError
|
|
|
|
from deerflow.config.agents_config import AgentConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def parse_agent_config(data: dict[str, Any], name: str) -> AgentConfig:
|
|
"""Build an :class:`AgentConfig` from a raw config *document*, shared by both backends.
|
|
|
|
Sets ``name`` from the natural key when the document omits it and strips
|
|
unknown keys (e.g. a legacy ``prompt_file``) before validation — identical
|
|
to the pre-refactor ``load_agent_config``.
|
|
"""
|
|
data = dict(data)
|
|
if "name" not in data:
|
|
data["name"] = name
|
|
known_fields = set(AgentConfig.model_fields.keys())
|
|
data = {k: v for k, v in data.items() if k in known_fields}
|
|
# A bare ``description:`` in YAML, or a null the agents API used to store,
|
|
# means "no description"; read it as the default instead of failing.
|
|
if "description" in data and data["description"] is None:
|
|
data["description"] = ""
|
|
try:
|
|
return AgentConfig(**data)
|
|
except ValidationError as exc:
|
|
if not any(error["loc"] == ("display_name",) for error in exc.errors()):
|
|
raise
|
|
# A cosmetic value in old/hand-edited storage must not make the agent
|
|
# inaccessible. Retry only without that field: other errors still fail.
|
|
data.pop("display_name", None)
|
|
config = AgentConfig(**data)
|
|
logger.warning("Ignoring invalid stored agent display_name for agent %r", name)
|
|
return config
|
|
|
|
|
|
# Delete outcome, mirroring the agents router's result:
|
|
# a row/dir was removed ("deleted"); only a legacy shared-layout entry exists,
|
|
# which the current write path never removes ("legacy"); nothing was there
|
|
# ("missing"); or a per-user directory exists holding memory/facts data but is
|
|
# not a custom agent (no config.yaml), so it is preserved rather than deleting a
|
|
# user's memory ("not-custom-agent", #4279).
|
|
AgentDeleteOutcome = Literal["deleted", "legacy", "missing", "not-custom-agent"]
|
|
|
|
|
|
class AgentExistsError(Exception):
|
|
"""Raised by :meth:`AgentStore.create` when ``(user_id, name)`` already exists."""
|
|
|
|
|
|
class AgentStore(abc.ABC):
|
|
@abc.abstractmethod
|
|
def get(self, name: str, *, user_id: str | None = None) -> AgentConfig:
|
|
"""Return the agent's config.
|
|
|
|
Raises :class:`FileNotFoundError` if the agent does not exist — the
|
|
historical contract that ``routers/agents.py`` and ``update_agent`` rely
|
|
on to surface a 404 / "does not exist" error.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def exists(self, name: str, *, user_id: str | None = None) -> bool:
|
|
"""Return whether ``name`` is already taken for ``user_id``.
|
|
|
|
Consistent with :meth:`create`'s conflict rule (so an "available" name
|
|
never then 409s): the file backend treats any per-user or legacy
|
|
directory as taken; the db backend checks for a row.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def get_soul(self, name: str, *, user_id: str | None = None) -> str | None:
|
|
"""Return the agent's ``SOUL.md`` content, or ``None`` if unset/empty."""
|
|
|
|
@abc.abstractmethod
|
|
def list(self, *, user_id: str | None = None) -> list[AgentConfig]:
|
|
"""Return every custom agent owned by ``user_id``, sorted by name."""
|
|
|
|
@abc.abstractmethod
|
|
def list_all(self) -> list[tuple[str, AgentConfig]]:
|
|
"""Return ``(user_id, config)`` for every agent across all owners.
|
|
|
|
Used by the GitHub registry, which scans all users' agents for repo
|
|
bindings. Ordering is deterministic (by ``user_id`` then name).
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def create(self, name: str, config: dict, soul: str, *, user_id: str | None = None) -> None:
|
|
"""Persist a new agent from the config *document* each write surface builds.
|
|
|
|
``config`` is the raw dict the caller assembled (the same one previously
|
|
written to ``config.yaml``); passing the document rather than a
|
|
re-serialized :class:`AgentConfig` keeps the on-disk bytes and the
|
|
"only present keys are written" behaviour identical to the pre-refactor
|
|
writers. Raises :class:`AgentExistsError` on an existing ``(user_id, name)``.
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def update(self, name: str, config: dict | None, soul: str | None, *, user_id: str | None = None) -> None:
|
|
"""Write an agent's config and/or soul (upsert).
|
|
|
|
``config`` / ``soul`` are each independently optional: ``None`` means
|
|
"leave that part unchanged" — the agents router updates config only when
|
|
a config field changed, and soul only when one was supplied. Creates the
|
|
record if it does not exist (the ``setup_agent`` / first-write path).
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def delete(self, name: str, *, user_id: str | None = None) -> AgentDeleteOutcome:
|
|
"""Delete an agent and its co-located memory, returning the outcome."""
|
|
|
|
@abc.abstractmethod
|
|
def signature(self) -> Hashable:
|
|
"""Return an opaque change token for cache invalidation.
|
|
|
|
Equal tokens mean "nothing changed since last read". The GitHub registry
|
|
keys its cache off this instead of ``stat()`` so it works for both
|
|
backends (mtime triples for ``file``; a deterministic digest of stored
|
|
agent contents for ``db``).
|
|
"""
|