wutongyuonce fb722770e4
fix(agents): do not hide invalid config with file fallback (#4952)
* fix(agents): do not hide invalid config with file fallback

* test(agents): cover invalid on-disk config fallback

* fix(agents): resolve stores off the event loop

* fix(agents): distinguish missing nested config from main config

* fix(agents): reject missing explicit config path

* test(agents): isolate config fallback test

* test(agents): isolate router blocking IO coverage

* test(agents): pin malformed config.yaml parse-error propagation

An unparseable config.yaml used to be swallowed by the broad
except Exception and silently downgrade to FileAgentStore. The
narrowed except FileNotFoundError already propagates
yaml.ParserError/ScannerError; pin that contract with a real
on-disk config instead of monkeypatched get_app_config.
2026-09-02 12:34:09 +08:00

107 lines
4.0 KiB
Python

"""Custom agent definition persistence — abstract store + file/db backends.
The public entry point is :func:`get_agent_store`, which the free functions in
:mod:`deerflow.config.agents_config` dispatch to. ``file`` (default) preserves
today's on-disk layout; ``db`` shares definitions across nodes via the SQL
persistence layer.
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from deerflow.persistence.agents.base import (
AgentDeleteOutcome,
AgentExistsError,
AgentStore,
parse_agent_config,
)
from deerflow.persistence.agents.model import AgentRow
if TYPE_CHECKING:
from deerflow.config.app_config import AppConfig
__all__ = [
"AgentDeleteOutcome",
"AgentExistsError",
"AgentRow",
"AgentStore",
"get_agent_store",
"make_agent_store",
"parse_agent_config",
]
_file_store_singleton: AgentStore | None = None
def make_agent_store(config: AppConfig) -> AgentStore:
"""Build (or reuse) the store selected by ``config.agent_storage.backend``.
``db`` requires ``database.backend`` to be ``sqlite`` or ``postgres``; a
``memory`` database has no durable URL and is rejected here (the gateway
also fails fast at startup, but this guard covers the graph-process path).
"""
if config.agent_storage.backend == "db":
db_backend = config.database.backend
if db_backend not in ("sqlite", "postgres"):
raise ValueError(
f"agent_storage.backend='db' requires database.backend to be 'sqlite' or 'postgres', "
f"but database.backend is '{db_backend}'. A 'memory' database is per-process and cannot "
"share agent definitions across nodes; set database.backend accordingly or use "
"agent_storage.backend='file'."
)
from deerflow.persistence.agents.sql import SqlAgentStore
return SqlAgentStore(config.database.app_sync_sqlalchemy_url)
return _file_store()
def get_agent_store() -> AgentStore:
"""Return the store for the current process's configuration.
Defaults to the file backend only when search mode cannot find the main app
config — the free functions in ``agents_config`` must keep working in
lightweight contexts (CLI, tests, tools) that never load a full
``config.yaml``. A missing explicit ``DEER_FLOW_CONFIG_PATH`` or a missing
nested config is an operator error and propagates instead of falling back.
Only an explicit ``agent_storage.backend: db`` diverges from the file
default once configuration resolves successfully.
Cross-process invariant (the ``db`` backend's whole point): the per-run
agent build runs in the **graph subprocess**, a different process from the
gateway. Its cross-node guarantee holds only because ``get_app_config()``
resolves ``config.yaml`` there too and returns ``backend: db`` — so the read
path in the graph process sees the same shared table the gateway wrote. The
``except`` below is a genuine *no resolvable config* fallback (CLI/tests),
**not** a mask for a misconfigured graph process: if ``config.yaml`` is
reachable there (it is, same working tree), ``db`` is honoured, not silently
downgraded to node-local ``file``. Pinned by
``test_get_agent_store_resolves_db_backend_from_on_disk_config``.
"""
from deerflow.config.app_config import AppConfig, get_app_config
try:
config = get_app_config()
except FileNotFoundError:
if os.getenv("DEER_FLOW_CONFIG_PATH"):
raise
# ``get_app_config()`` also loads optional nested config files. Only
# fall back when the main config itself is absent.
try:
AppConfig.resolve_config_path()
except FileNotFoundError:
return _file_store()
raise
return make_agent_store(config)
def _file_store() -> AgentStore:
global _file_store_singleton
if _file_store_singleton is None:
from deerflow.persistence.agents.file import FileAgentStore
_file_store_singleton = FileAgentStore()
return _file_store_singleton