mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* fix(config): revalidate the extensions config singleton by file signature `get_extensions_config()` parsed `extensions_config.json` once per process and only the process that handled a write ever reloaded it. With several uvicorn workers, or several Pods sharing one volume, every other process kept its startup copy: the MCP servers the agent could use and the local-bash absolute path allowlist derived from the filesystem MCP server differed between replicas until each one restarted. The singleton now records the resolved path and the file's (mtime, size, sha256) signature and reloads when either changes, like `get_app_config()` does for `config.yaml` and `mcp/cache.py` does for the tools cache. After a successful load, a revision that cannot be loaded (file gone, truncated, invalid) keeps the last-known-good configuration and is logged once with the exception type only; the first load still raises. `set_extensions_config()` pins the injected instance until a reload or reset. The cached AppConfig's `extensions` snapshot is built from the singleton instance and refreshes when that instance changes, so it inherits the same fail-soft policy instead of parsing the file again. * docs(changelog): link the extensions config freshness entry to #6386 * fix(config): follow an explicitly reloaded extensions config path `reload_extensions_config(config_path=X)` recorded X, but the freshness probe re-resolved the default path, so the next read either flipped the cache back to the default file or pinned a stale copy of X. The cache now remembers the explicit path and both the probe and later reloads follow it until a reset or an argument-less reload. Also shortens the pointer in backend/AGENTS.md so the nested guidance chains stay under the hard size limit enforced by the agent-guidance check. * docs: keep backend guidance within the inherited size budget * fix(config): reload the probed extensions config path --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
351 lines
14 KiB
Python
351 lines
14 KiB
Python
"""Agent-storage backend selection, startup validation, and the db importer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
import yaml
|
|
from sqlalchemy import create_engine
|
|
|
|
from app.gateway.deps import _validate_agent_storage
|
|
from deerflow.config.agent_storage_config import AgentStorageConfig
|
|
from deerflow.config.app_config import reset_app_config
|
|
from deerflow.config.database_config import DatabaseConfig
|
|
from deerflow.config.extensions_config import reset_extensions_config
|
|
from deerflow.persistence.agents import get_agent_store, make_agent_store
|
|
from deerflow.persistence.agents.file import FileAgentStore
|
|
from deerflow.persistence.agents.model import AgentRow
|
|
from deerflow.persistence.agents.sql import SqlAgentStore
|
|
from deerflow.persistence.base import Base
|
|
from deerflow.persistence.managed_subagents.base import ManagedSubagentDefinition
|
|
from deerflow.persistence.managed_subagents.file import FileManagedSubagentStore
|
|
from deerflow.persistence.managed_subagents.model import ManagedSubagentRow
|
|
from deerflow.persistence.managed_subagents.sql import SqlManagedSubagentStore
|
|
|
|
|
|
def _cfg(agent_backend: str, db_backend: str, sqlite_dir: str = "/tmp/agent-store-test") -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
agent_storage=AgentStorageConfig(backend=agent_backend),
|
|
database=DatabaseConfig(backend=db_backend, sqlite_dir=sqlite_dir),
|
|
)
|
|
|
|
|
|
# -- make_agent_store selection --------------------------------------------
|
|
|
|
|
|
def test_file_is_the_default_backend():
|
|
assert isinstance(make_agent_store(_cfg("file", "memory")), FileAgentStore)
|
|
|
|
|
|
def test_db_backend_builds_sql_store(tmp_path):
|
|
assert isinstance(make_agent_store(_cfg("db", "sqlite", str(tmp_path))), SqlAgentStore)
|
|
|
|
|
|
def test_db_backend_on_memory_database_is_rejected():
|
|
with pytest.raises(ValueError, match="requires database.backend"):
|
|
make_agent_store(_cfg("db", "memory"))
|
|
|
|
|
|
# -- startup validation (deps) ---------------------------------------------
|
|
|
|
|
|
def test_validation_rejects_db_on_memory_database():
|
|
with pytest.raises(SystemExit):
|
|
_validate_agent_storage(_cfg("db", "memory"))
|
|
|
|
|
|
def test_validation_allows_file_and_db_on_sql(tmp_path):
|
|
_validate_agent_storage(_cfg("file", "memory")) # no raise
|
|
_validate_agent_storage(_cfg("db", "sqlite", str(tmp_path))) # no raise
|
|
|
|
|
|
def test_validation_warns_on_file_under_multiworker_postgres(monkeypatch, caplog):
|
|
monkeypatch.setenv("GATEWAY_WORKERS", "4")
|
|
cfg = SimpleNamespace(
|
|
agent_storage=AgentStorageConfig(backend="file"),
|
|
database=DatabaseConfig(backend="postgres", postgres_url="postgresql://u:p@h/db"),
|
|
)
|
|
with caplog.at_level("WARNING"):
|
|
_validate_agent_storage(cfg)
|
|
assert any("not visible across workers" in r.message for r in caplog.records)
|
|
|
|
|
|
# -- importer: file layout → db --------------------------------------------
|
|
|
|
|
|
@pytest.fixture()
|
|
def file_home(tmp_path, monkeypatch):
|
|
"""Root file stores at a temp DEER_FLOW_HOME with seeded definitions."""
|
|
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
|
from deerflow.config import paths as paths_module
|
|
|
|
monkeypatch.setattr(paths_module, "_paths", None)
|
|
fs = FileAgentStore()
|
|
fs.create("reviewer", {"name": "reviewer", "description": "reviews"}, "review soul", user_id="u1")
|
|
fs.create("planner", {"name": "planner", "description": "plans", "model": "m1"}, "plan soul", user_id="u2")
|
|
FileManagedSubagentStore().create(
|
|
ManagedSubagentDefinition(
|
|
name="researcher",
|
|
description="Researches topics",
|
|
system_prompt="Research carefully.",
|
|
enabled=False,
|
|
)
|
|
)
|
|
return tmp_path
|
|
|
|
|
|
def _patch_importer(monkeypatch, cfg):
|
|
import pathlib
|
|
|
|
import scripts.migrate_agents_to_db as importer
|
|
|
|
async def _noop_init(_):
|
|
return None
|
|
|
|
# Pre-create the schema (stand in for the alembic bootstrap the real run
|
|
# does, which also creates the sqlite directory).
|
|
pathlib.Path(cfg.database.sqlite_dir).mkdir(parents=True, exist_ok=True)
|
|
engine = create_engine(cfg.database.app_sync_sqlalchemy_url)
|
|
Base.metadata.create_all(engine, tables=[AgentRow.__table__, ManagedSubagentRow.__table__])
|
|
engine.dispose()
|
|
|
|
monkeypatch.setattr(importer, "get_app_config", lambda: cfg)
|
|
monkeypatch.setattr("deerflow.persistence.engine.init_engine_from_config", _noop_init)
|
|
return importer
|
|
|
|
|
|
def test_importer_copies_all_definitions_into_db(file_home, monkeypatch):
|
|
cfg = _cfg("db", "sqlite", str(file_home / "db"))
|
|
importer = _patch_importer(monkeypatch, cfg)
|
|
monkeypatch.setattr(sys, "argv", ["migrate_agents_to_db"])
|
|
|
|
assert importer.main() == 0
|
|
|
|
dest = SqlAgentStore(cfg.database.app_sync_sqlalchemy_url)
|
|
assert dest.get("reviewer", user_id="u1").description == "reviews"
|
|
assert dest.get_soul("reviewer", user_id="u1") == "review soul"
|
|
assert dest.get("planner", user_id="u2").model == "m1"
|
|
managed = SqlManagedSubagentStore(cfg.database.app_sync_sqlalchemy_url).get("researcher")
|
|
assert managed.description == "Researches topics"
|
|
assert managed.enabled is False
|
|
|
|
|
|
def test_importer_is_idempotent(file_home, monkeypatch):
|
|
cfg = _cfg("db", "sqlite", str(file_home / "db"))
|
|
importer = _patch_importer(monkeypatch, cfg)
|
|
monkeypatch.setattr(sys, "argv", ["migrate_agents_to_db"])
|
|
|
|
assert importer.main() == 0
|
|
# Second run must not raise on the already-present rows.
|
|
assert importer.main() == 0
|
|
dest = SqlAgentStore(cfg.database.app_sync_sqlalchemy_url)
|
|
assert len(dest.list_all()) == 2
|
|
assert [item.name for item in SqlManagedSubagentStore(cfg.database.app_sync_sqlalchemy_url).list()] == ["researcher"]
|
|
|
|
|
|
def test_importer_dry_run_writes_nothing(file_home, monkeypatch):
|
|
cfg = _cfg("db", "sqlite", str(file_home / "db"))
|
|
importer = _patch_importer(monkeypatch, cfg)
|
|
monkeypatch.setattr(sys, "argv", ["migrate_agents_to_db", "--dry-run"])
|
|
|
|
assert importer.main() == 0
|
|
dest = SqlAgentStore(cfg.database.app_sync_sqlalchemy_url)
|
|
assert dest.list_all() == []
|
|
assert SqlManagedSubagentStore(cfg.database.app_sync_sqlalchemy_url).list() == []
|
|
|
|
|
|
def test_importer_runs_when_only_managed_subagents_exist(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
|
from deerflow.config import paths as paths_module
|
|
|
|
monkeypatch.setattr(paths_module, "_paths", None)
|
|
FileManagedSubagentStore().create(
|
|
ManagedSubagentDefinition(
|
|
name="writer",
|
|
description="Writes reports",
|
|
system_prompt="Write clearly.",
|
|
)
|
|
)
|
|
|
|
cfg = _cfg("db", "sqlite", str(tmp_path / "db"))
|
|
importer = _patch_importer(monkeypatch, cfg)
|
|
monkeypatch.setattr(sys, "argv", ["migrate_agents_to_db"])
|
|
|
|
assert importer.main() == 0
|
|
|
|
assert SqlAgentStore(cfg.database.app_sync_sqlalchemy_url).list_all() == []
|
|
assert SqlManagedSubagentStore(cfg.database.app_sync_sqlalchemy_url).get("writer").description == "Writes reports"
|
|
|
|
|
|
def test_read_free_functions_dispatch_to_db_backend(file_home, monkeypatch):
|
|
"""The headline invariant: under the db backend the standard read path (the
|
|
same free functions the per-run agent build calls) resolves from the shared
|
|
DB, not from node-local files — so on-disk agents are invisible and db agents
|
|
are visible everywhere."""
|
|
cfg = _cfg("db", "sqlite", str(file_home / "db"))
|
|
_patch_importer(monkeypatch, cfg) # creates the schema
|
|
monkeypatch.setattr("deerflow.config.app_config.get_app_config", lambda: cfg)
|
|
|
|
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul
|
|
|
|
# The file store seeded 'reviewer'/'planner' on disk; the db is empty, so
|
|
# the free functions (now db-backed) do not see them.
|
|
assert list_custom_agents(user_id="u1") == []
|
|
with pytest.raises(FileNotFoundError):
|
|
load_agent_config("reviewer", user_id="u1")
|
|
|
|
# An agent written to the shared db is visible through the same free functions.
|
|
SqlAgentStore(cfg.database.app_sync_sqlalchemy_url).create("dbonly", {"name": "dbonly", "description": "shared"}, "db soul", user_id="u1")
|
|
assert [c.name for c in list_custom_agents(user_id="u1")] == ["dbonly"]
|
|
assert load_agent_config("dbonly", user_id="u1").description == "shared"
|
|
assert load_agent_soul("dbonly", user_id="u1") == "db soul"
|
|
|
|
|
|
def test_file_create_race_maps_file_exists_to_agent_exists(tmp_path, monkeypatch):
|
|
# TOCTOU: the existence guard passes (no agent yet), but a concurrent create
|
|
# wins the race so mkdir(exist_ok=False) raises FileExistsError. The store
|
|
# must translate that to AgentExistsError so the router returns 409, not a
|
|
# generic 500 — matching SqlAgentStore's IntegrityError path.
|
|
import pathlib
|
|
|
|
from deerflow.persistence.agents.base import AgentExistsError
|
|
|
|
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
|
from deerflow.config import paths as paths_module
|
|
|
|
monkeypatch.setattr(paths_module, "_paths", None)
|
|
|
|
def _racing_mkdir(self, *args, **kwargs):
|
|
raise FileExistsError(str(self))
|
|
|
|
monkeypatch.setattr(pathlib.Path, "mkdir", _racing_mkdir)
|
|
|
|
fs = FileAgentStore()
|
|
with pytest.raises(AgentExistsError):
|
|
fs.create("racy", {"name": "racy"}, "soul", user_id="u1")
|
|
|
|
|
|
# -- graph-subprocess config resolution (db backend's core cross-process invariant) --
|
|
|
|
|
|
def _write_min_config(path, extra: dict) -> None:
|
|
"""Minimal but valid config.yaml (sandbox + models are the only hard requirements)."""
|
|
doc = {
|
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
|
"models": [{"name": "m", "use": "langchain_openai:ChatOpenAI", "model": "gpt-test"}],
|
|
**extra,
|
|
}
|
|
path.write_text(yaml.safe_dump(doc), encoding="utf-8")
|
|
|
|
|
|
def test_get_agent_store_resolves_db_backend_from_on_disk_config(tmp_path, monkeypatch):
|
|
"""Pins the db backend's headline cross-process guarantee.
|
|
|
|
The per-run agent build runs in the graph subprocess, a different process
|
|
from the gateway; its db visibility holds only because ``get_agent_store()``
|
|
resolves ``agent_storage.backend: db`` from the real on-disk ``config.yaml``
|
|
there (not a monkeypatched stub) rather than silently falling back to
|
|
node-local ``file``. Existing coverage monkeypatches ``get_app_config``;
|
|
this drives the genuine file-resolution path a fresh process would take.
|
|
"""
|
|
cfg_path = tmp_path / "config.yaml"
|
|
_write_min_config(cfg_path, {"agent_storage": {"backend": "db"}, "database": {"backend": "sqlite", "sqlite_dir": str(tmp_path / "db")}})
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(cfg_path))
|
|
try:
|
|
reset_app_config() # force a fresh read from the on-disk file
|
|
assert isinstance(get_agent_store(), SqlAgentStore)
|
|
finally:
|
|
reset_app_config() # don't leak the custom config into other tests
|
|
|
|
|
|
def test_get_agent_store_falls_back_to_file_without_config(tmp_path, monkeypatch):
|
|
"""The ``except -> file`` fallback is for genuinely unresolvable config only
|
|
(CLI/tests); it must not fire when a config exists — that asymmetry is what
|
|
keeps a misconfigured graph process from silently downgrading db to file."""
|
|
monkeypatch.delenv("DEER_FLOW_CONFIG_PATH", raising=False)
|
|
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path))
|
|
from deerflow.config import app_config
|
|
|
|
monkeypatch.setattr(app_config, "_legacy_config_candidates", lambda: ())
|
|
try:
|
|
reset_app_config()
|
|
assert isinstance(get_agent_store(), FileAgentStore)
|
|
finally:
|
|
reset_app_config()
|
|
|
|
|
|
def test_get_agent_store_does_not_fallback_when_explicit_config_is_missing(tmp_path, monkeypatch):
|
|
"""An explicit config path is an operator assertion and must fail closed."""
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(tmp_path / "does-not-exist.yaml"))
|
|
try:
|
|
reset_app_config()
|
|
with pytest.raises(FileNotFoundError, match="DEER_FLOW_CONFIG_PATH"):
|
|
get_agent_store()
|
|
finally:
|
|
reset_app_config()
|
|
|
|
|
|
def test_get_agent_store_does_not_hide_invalid_config(monkeypatch):
|
|
"""Only missing config falls back; config errors must reach the caller."""
|
|
from deerflow.config import app_config
|
|
|
|
def raise_invalid_config():
|
|
raise ValueError("invalid config")
|
|
|
|
monkeypatch.setattr(app_config, "get_app_config", raise_invalid_config)
|
|
with pytest.raises(ValueError, match="invalid config"):
|
|
get_agent_store()
|
|
|
|
|
|
def test_get_agent_store_propagates_invalid_on_disk_config(tmp_path, monkeypatch):
|
|
"""A present config with an invalid backend must fail instead of falling back."""
|
|
cfg_path = tmp_path / "config.yaml"
|
|
_write_min_config(cfg_path, {"agent_storage": {"backend": "invalid"}})
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(cfg_path))
|
|
try:
|
|
reset_app_config()
|
|
with pytest.raises(ValueError, match="agent_storage.backend"):
|
|
get_agent_store()
|
|
finally:
|
|
reset_app_config()
|
|
|
|
|
|
def test_get_agent_store_propagates_malformed_yaml(tmp_path, monkeypatch):
|
|
"""An unparseable config.yaml must surface the parse error, not fall back to file."""
|
|
cfg_path = tmp_path / "config.yaml"
|
|
cfg_path.write_text("agent_storage: [oops\n", encoding="utf-8")
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(cfg_path))
|
|
try:
|
|
reset_app_config()
|
|
with pytest.raises(yaml.YAMLError): # ParserError/ScannerError, previously swallowed
|
|
get_agent_store()
|
|
finally:
|
|
reset_app_config()
|
|
|
|
|
|
def test_get_agent_store_does_not_fallback_when_extensions_config_is_missing(tmp_path, monkeypatch):
|
|
"""A missing nested config must not look like a missing main config."""
|
|
cfg_path = tmp_path / "config.yaml"
|
|
_write_min_config(
|
|
cfg_path,
|
|
{
|
|
"agent_storage": {"backend": "db"},
|
|
"database": {"backend": "sqlite", "sqlite_dir": str(tmp_path / "db")},
|
|
},
|
|
)
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(cfg_path))
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(tmp_path / "missing-extensions.json"))
|
|
try:
|
|
reset_app_config()
|
|
# Startup scenario: an already-loaded extensions singleton would keep
|
|
# its last-known-good copy when the file disappears, so clear it to
|
|
# exercise the first load, which must still fail loudly.
|
|
reset_extensions_config()
|
|
with pytest.raises(FileNotFoundError, match="Extensions config"):
|
|
get_agent_store()
|
|
finally:
|
|
reset_app_config()
|
|
reset_extensions_config()
|