deer-flow/backend/tests/test_app_config_reload.py
Nan Gao 4f64ca2ae0
fix(config): revalidate the extensions config singleton by file signature (#6386)
* 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>
2026-10-07 09:39:26 +08:00

1110 lines
43 KiB
Python

from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
import yaml
from pydantic import ValidationError
import deerflow.config.app_config as app_config_module
import deerflow.config.extensions_config as extensions_config_module
from deerflow.config.acp_config import load_acp_config_from_dict
from deerflow.config.agents_api_config import get_agents_api_config, load_agents_api_config_from_dict
from deerflow.config.app_config import AppConfig, get_app_config, peek_loaded_app_config, reset_app_config
from deerflow.config.checkpointer_config import get_checkpointer_config, load_checkpointer_config_from_dict
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.extensions_config import (
ExtensionsConfig,
atomic_write_extensions_config,
reload_extensions_config,
reset_extensions_config,
set_extensions_config,
)
from deerflow.config.guardrails_config import get_guardrails_config, load_guardrails_config_from_dict
from deerflow.config.memory_config import get_memory_config, load_memory_config_from_dict
from deerflow.config.stream_bridge_config import get_stream_bridge_config, load_stream_bridge_config_from_dict
from deerflow.config.subagents_config import get_subagents_app_config, load_subagents_config_from_dict
from deerflow.config.summarization_config import get_summarization_config, load_summarization_config_from_dict
from deerflow.config.title_config import get_title_config, load_title_config_from_dict
from deerflow.config.tool_search_config import get_tool_search_config, load_tool_search_config_from_dict
from deerflow.runtime.checkpointer import get_checkpointer, reset_checkpointer
from deerflow.runtime.store import get_store, reset_store
from deerflow.storage import BlobNotConfiguredError, get_blob_store, get_blob_store_if_enabled, reset_blob_store
def _reset_config_singletons() -> None:
reset_extensions_config()
load_title_config_from_dict({})
load_summarization_config_from_dict({})
load_memory_config_from_dict({})
load_agents_api_config_from_dict({})
load_subagents_config_from_dict({})
load_tool_search_config_from_dict({})
load_guardrails_config_from_dict({})
load_checkpointer_config_from_dict(None)
load_stream_bridge_config_from_dict(None)
load_acp_config_from_dict({})
reset_checkpointer()
reset_store()
reset_app_config()
def _write_config(path: Path, *, model_name: str, supports_thinking: bool) -> None:
path.write_text(
yaml.safe_dump(
{
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"models": [
{
"name": model_name,
"use": "langchain_openai:ChatOpenAI",
"model": "gpt-test",
"supports_thinking": supports_thinking,
}
],
}
),
encoding="utf-8",
)
def _write_config_with_agents_api(
path: Path,
*,
model_name: str,
supports_thinking: bool,
agents_api: dict | None = None,
) -> None:
config = {
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"models": [
{
"name": model_name,
"use": "langchain_openai:ChatOpenAI",
"model": "gpt-test",
"supports_thinking": supports_thinking,
}
],
}
if agents_api is not None:
config["agents_api"] = agents_api
path.write_text(yaml.safe_dump(config), encoding="utf-8")
def _write_config_with_sections(path: Path, sections: dict | None = None) -> None:
config = {
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"models": [
{
"name": "first-model",
"use": "langchain_openai:ChatOpenAI",
"model": "gpt-test",
}
],
}
if sections:
config.update(sections)
path.write_text(yaml.safe_dump(config), encoding="utf-8")
def _write_extensions_config(path: Path, *, middlewares: list[str] | None = None) -> None:
payload: dict = {"mcpServers": {}, "skills": {}}
if middlewares is not None:
payload["middlewares"] = middlewares
path.write_text(json.dumps(payload), encoding="utf-8")
def _stage_singleton_configs(tmp_path: Path, monkeypatch, *, middlewares: list[str]) -> Path:
"""Stage config.yaml plus an extensions file and point the singletons at them."""
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path, middlewares=middlewares)
_write_config(config_path, model_name="model-a", supports_thinking=False)
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
_reset_config_singletons()
return extensions_path
def _peer_writes_extensions(path: Path, *, middlewares: list[str], skills: dict | None = None) -> None:
"""Another Gateway process rewrites the shared file without reloading this one."""
atomic_write_extensions_config(path, {"mcpServers": {}, "skills": skills or {}, "middlewares": middlewares})
def test_checkpoint_channel_mode_defaults_to_full() -> None:
assert DatabaseConfig().checkpoint_channel_mode == "full"
@pytest.mark.parametrize("mode", ["full", "delta"])
def test_checkpoint_channel_mode_accepts_supported_values(mode: str) -> None:
assert DatabaseConfig(checkpoint_channel_mode=mode).checkpoint_channel_mode == mode
def test_checkpoint_channel_mode_rejects_unknown_value() -> None:
with pytest.raises(ValidationError):
DatabaseConfig(checkpoint_channel_mode="auto")
def test_checkpoint_delta_defaults_to_production_cadence() -> None:
assert DatabaseConfig().checkpoint_delta.snapshot_frequency == 10
def test_checkpoint_delta_accepts_custom_snapshot_frequency() -> None:
assert DatabaseConfig(checkpoint_delta={"snapshot_frequency": 250}).checkpoint_delta.snapshot_frequency == 250
@pytest.mark.parametrize("value", [0, -1])
def test_checkpoint_delta_rejects_non_positive_snapshot_frequency(value: int) -> None:
with pytest.raises(ValidationError):
DatabaseConfig(checkpoint_delta={"snapshot_frequency": value})
def test_legacy_snapshot_frequency_maps_to_nested_key(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level("WARNING"):
config = DatabaseConfig(checkpoint_delta_snapshot_frequency=1000)
assert config.checkpoint_delta.snapshot_frequency == 1000
assert "checkpoint_delta_snapshot_frequency is deprecated" in caplog.text
def test_nested_snapshot_frequency_wins_over_legacy_key(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level("WARNING"):
config = DatabaseConfig(
checkpoint_delta_snapshot_frequency=1000,
checkpoint_delta={"snapshot_frequency": 250},
)
assert config.checkpoint_delta.snapshot_frequency == 250
assert "the nested key wins" in caplog.text
@pytest.mark.parametrize("value", [0, -1])
def test_legacy_snapshot_frequency_rejects_non_positive_value(value: int) -> None:
with pytest.raises(ValidationError):
DatabaseConfig(checkpoint_delta_snapshot_frequency=value)
def test_checkpoint_graph_cache_defaults() -> None:
assert DatabaseConfig().checkpoint_graph_cache.accessor_graph_max == 64
def test_checkpoint_graph_cache_accepts_custom_cap() -> None:
assert DatabaseConfig(checkpoint_graph_cache={"accessor_graph_max": 8}).checkpoint_graph_cache.accessor_graph_max == 8
@pytest.mark.parametrize("value", [0, -1])
def test_checkpoint_graph_cache_rejects_non_positive_cap(value: int) -> None:
with pytest.raises(ValidationError):
DatabaseConfig(checkpoint_graph_cache={"accessor_graph_max": value})
def test_resolve_checkpoint_graph_cache_max_tolerates_stub_configs() -> None:
from types import SimpleNamespace
from unittest.mock import MagicMock
from deerflow.config.database_config import resolve_checkpoint_graph_cache_max
assert resolve_checkpoint_graph_cache_max(None, "accessor_graph_max", 64) == 64
assert resolve_checkpoint_graph_cache_max(SimpleNamespace(), "accessor_graph_max", 64) == 64
assert resolve_checkpoint_graph_cache_max(MagicMock(), "accessor_graph_max", 64) == 64
stub = SimpleNamespace(checkpoint_graph_cache=SimpleNamespace(accessor_graph_max=3))
assert resolve_checkpoint_graph_cache_max(stub, "accessor_graph_max", 64) == 3
def test_config_example_does_not_enable_empty_extensions_block_by_default():
config_example_path = Path(__file__).resolve().parents[2] / "config.example.yaml"
config_data = yaml.safe_load(config_example_path.read_text(encoding="utf-8"))
assert "extensions" not in config_data
def test_app_config_defaults_missing_database_to_sqlite(tmp_path, monkeypatch):
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
_write_config(config_path, model_name="first-model", supports_thinking=False)
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
config = AppConfig.from_file(str(config_path))
assert config.database.backend == "sqlite"
assert config.database.sqlite_dir == ".deer-flow/data"
def test_app_config_preserves_config_yaml_extension_middlewares(tmp_path, monkeypatch):
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
extensions_path.write_text(
json.dumps({"mcpServers": {}, "skills": {}, "middlewares": ["pkg.from_file:FileMiddleware"]}),
encoding="utf-8",
)
_write_config_with_sections(
config_path,
{
"extensions": {
"middlewares": ["pkg.from_yaml:YamlMiddleware"],
}
},
)
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
config = AppConfig.from_file(str(config_path))
assert config.extensions.middlewares == ["pkg.from_yaml:YamlMiddleware"]
def test_app_config_normalizes_config_yaml_extension_aliases_before_override(tmp_path, monkeypatch):
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
extensions_path.write_text(
json.dumps(
{
"mcpServers": {
"from-file": {
"command": "file-mcp",
}
},
"skills": {},
}
),
encoding="utf-8",
)
_write_config_with_sections(
config_path,
{
"extensions": {
"mcp_servers": {
"from-yaml": {
"command": "yaml-mcp",
}
},
}
},
)
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
config = AppConfig.from_file(str(config_path))
assert set(config.extensions.mcp_servers) == {"from-yaml"}
assert config.extensions.mcp_servers["from-yaml"].command == "yaml-mcp"
def test_app_config_loads_extension_middlewares_from_extensions_config(tmp_path, monkeypatch):
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
extensions_path.write_text(
json.dumps({"mcpServers": {}, "skills": {}, "middlewares": ["pkg.from_file:FileMiddleware"]}),
encoding="utf-8",
)
_write_config_with_sections(config_path)
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
config = AppConfig.from_file(str(config_path))
assert config.extensions.middlewares == ["pkg.from_file:FileMiddleware"]
def test_app_config_loads_middleware_kwargs_from_config_yaml(tmp_path, monkeypatch):
from deerflow.config.extensions_config import ConfiguredMiddlewareSpec
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
extensions_path.write_text(
json.dumps({"mcpServers": {}, "skills": {}, "middlewares": ["pkg.from_file:FileMiddleware"]}),
encoding="utf-8",
)
_write_config_with_sections(
config_path,
{
"extensions": {
"middlewares": [
"pkg.from_yaml:PlainMiddleware",
{"class": "pkg.from_yaml:KwargsMiddleware", "kwargs": {"max_tool_calls": 4}},
],
}
},
)
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
config = AppConfig.from_file(str(config_path))
assert config.extensions.middlewares[0] == "pkg.from_yaml:PlainMiddleware"
spec = config.extensions.middlewares[1]
assert isinstance(spec, ConfiguredMiddlewareSpec)
assert spec.class_path == "pkg.from_yaml:KwargsMiddleware"
assert spec.kwargs == {"max_tool_calls": 4}
def test_app_config_defaults_empty_database_to_sqlite(tmp_path, monkeypatch):
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
config_path.write_text(
yaml.safe_dump(
{
"database": {},
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
}
),
encoding="utf-8",
)
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
config = AppConfig.from_file(str(config_path))
assert config.database.backend == "sqlite"
assert config.database.sqlite_dir == ".deer-flow/data"
def test_app_config_coerces_commented_out_list_sections(tmp_path, monkeypatch):
"""Commenting out every entry under a list key makes PyYAML parse it as None.
Regression for the documented ``cp config.example.yaml config.yaml`` flow
(issue #1444): such a config must load with empty lists instead of raising
``Input should be a valid list``.
"""
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
config_path.write_text(
yaml.safe_dump(
{
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"models": None,
"tools": None,
"tool_groups": None,
}
),
encoding="utf-8",
)
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
config = AppConfig.from_file(str(config_path))
assert config.models == []
assert config.tools == []
assert config.tool_groups == []
def test_app_config_coerces_commented_out_object_sections(tmp_path, monkeypatch):
"""Commenting out every entry under an object key makes PyYAML parse it as None.
Same documented ``cp config.example.yaml config.yaml`` flow as the list
sections: object sections (memory, summarization, ...) must fall back to
their defaults instead of raising ``Input should be a valid dictionary``.
"""
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
config_path.write_text(
yaml.safe_dump(
{
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"memory": None,
"summarization": None,
"guardrails": None,
"tool_output": None,
"run_events": None,
}
),
encoding="utf-8",
)
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
config = AppConfig.from_file(str(config_path))
# Each present-but-null object section falls back to a real default config
# object of the expected type (not merely non-None).
assert type(config.memory).__name__ == "MemoryConfig"
assert type(config.summarization).__name__ == "SummarizationConfig"
assert type(config.guardrails).__name__ == "GuardrailsConfig"
assert type(config.tool_output).__name__ == "ToolOutputConfig"
assert type(config.run_events).__name__ == "RunEventsConfig"
def test_app_config_null_required_section_still_errors(tmp_path, monkeypatch):
"""A present-but-null *required* section still errors.
``sandbox`` has no default, so dropping a ``sandbox: null`` key leaves the
required field absent — there is nothing to fall back to (per
``_drop_null_config_sections``), unlike the optional object sections above.
"""
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
config_path.write_text(yaml.safe_dump({"sandbox": None}), encoding="utf-8")
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
with pytest.raises(ValidationError):
AppConfig.from_file(str(config_path))
def test_app_config_warns_when_no_models_configured(tmp_path, monkeypatch, caplog):
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
config_path.write_text(
yaml.safe_dump(
{
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"models": None,
}
),
encoding="utf-8",
)
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
with caplog.at_level("WARNING", logger="deerflow.config.app_config"):
AppConfig.from_file(str(config_path))
assert "No models are configured" in caplog.text
def test_get_app_config_reloads_when_file_changes(tmp_path, monkeypatch):
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
_write_config(config_path, model_name="first-model", supports_thinking=False)
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
reset_app_config()
try:
initial = get_app_config()
assert initial.models[0].supports_thinking is False
_write_config(config_path, model_name="first-model", supports_thinking=True)
next_mtime = config_path.stat().st_mtime + 5
os.utime(config_path, (next_mtime, next_mtime))
reloaded = get_app_config()
assert reloaded.models[0].supports_thinking is True
assert reloaded is not initial
finally:
reset_app_config()
def test_peek_loaded_app_config_survives_a_missing_file(tmp_path, monkeypatch):
"""A host running on a config keeps that value after the file disappears.
This is the distinction ``app.gateway.authz`` needs for the plugin
authorization gate: a failed ``get_app_config()`` under a host that has a
configuration means "the policy I am running on is unreadable right now",
not "authorization was never configured".
"""
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
_write_config(config_path, model_name="model-a", supports_thinking=False)
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
reset_app_config()
try:
assert peek_loaded_app_config() is None
get_app_config()
config_path.unlink()
with pytest.raises(FileNotFoundError):
get_app_config()
loaded = peek_loaded_app_config()
assert loaded is not None
assert loaded.models[0].name == "model-a"
finally:
reset_app_config()
def test_get_app_config_reloads_when_content_digest_changes_without_metadata(tmp_path, monkeypatch):
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
_write_config(config_path, model_name="model-a", supports_thinking=False)
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
_reset_config_singletons()
try:
initial = get_app_config()
initial_mtime = app_config_module._app_config_mtime
initial_signature = app_config_module._app_config_signature
assert initial.models[0].name == "model-a"
assert initial_signature is not None
_write_config(config_path, model_name="model-b", supports_thinking=False)
real_get_config_signature = app_config_module._get_config_signature
def stale_metadata_signature(path: Path):
current_signature = real_get_config_signature(path)
assert current_signature is not None
return (initial_signature[0], initial_signature[1], current_signature[2])
real_read_config_with_signature = app_config_module._read_config_with_signature
def stale_metadata_reader(path: Path):
# The loader signs the bytes it parses; on a mount with stale
# metadata its stat sees the same stale mtime/size as the probe.
data, current_signature = real_read_config_with_signature(path)
return data, (initial_signature[0], initial_signature[1], current_signature[2])
monkeypatch.setattr(app_config_module, "_get_config_mtime", lambda _path: initial_mtime)
monkeypatch.setattr(app_config_module, "_get_config_signature", stale_metadata_signature)
monkeypatch.setattr(app_config_module, "_read_config_with_signature", stale_metadata_reader)
reloaded = get_app_config()
assert reloaded.models[0].name == "model-b"
assert reloaded is not initial
assert app_config_module._app_config_signature is not None
assert app_config_module._app_config_signature[:2] == initial_signature[:2]
assert app_config_module._app_config_signature[2] != initial_signature[2]
finally:
_reset_config_singletons()
def test_get_app_config_reloads_when_config_path_changes(tmp_path, monkeypatch):
config_a = tmp_path / "config-a.yaml"
config_b = tmp_path / "config-b.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
_write_config(config_a, model_name="model-a", supports_thinking=False)
_write_config(config_b, model_name="model-b", supports_thinking=True)
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_a))
reset_app_config()
try:
first = get_app_config()
assert first.models[0].name == "model-a"
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_b))
second = get_app_config()
assert second.models[0].name == "model-b"
assert second is not first
finally:
reset_app_config()
def test_get_app_config_resets_agents_api_config_when_section_removed(tmp_path, monkeypatch):
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
_write_config_with_agents_api(
config_path,
model_name="first-model",
supports_thinking=False,
agents_api={"enabled": True},
)
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
reset_app_config()
try:
initial = get_app_config()
assert initial.models[0].name == "first-model"
assert get_agents_api_config().enabled is True
_write_config_with_agents_api(
config_path,
model_name="first-model",
supports_thinking=False,
)
next_mtime = config_path.stat().st_mtime + 5
os.utime(config_path, (next_mtime, next_mtime))
reloaded = get_app_config()
assert reloaded is not initial
assert get_agents_api_config().enabled is False
finally:
reset_app_config()
def test_get_app_config_resets_singleton_configs_when_sections_removed(tmp_path, monkeypatch):
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
_write_config_with_sections(
config_path,
{
"title": {"enabled": False, "max_words": 3},
"summarization": {"enabled": True},
"memory": {"enabled": False, "max_facts": 50},
"subagents": {"timeout_seconds": 42, "agents": {"reviewer": {"max_turns": 2}}},
"tool_search": {"enabled": True},
"guardrails": {"enabled": True, "fail_closed": False},
"checkpointer": {"type": "memory"},
"stream_bridge": {"type": "memory", "queue_maxsize": 12},
},
)
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
reset_app_config()
try:
get_app_config()
assert get_title_config().enabled is False
assert get_summarization_config().enabled is True
assert get_memory_config().enabled is False
assert get_subagents_app_config().timeout_seconds == 42
assert get_tool_search_config().enabled is True
assert get_guardrails_config().enabled is True
assert get_checkpointer_config() is not None
assert get_stream_bridge_config() is not None
_write_config_with_sections(config_path)
next_mtime = config_path.stat().st_mtime + 5
os.utime(config_path, (next_mtime, next_mtime))
get_app_config()
assert get_title_config().enabled is True
assert get_summarization_config().enabled is False
assert get_memory_config().enabled is True
assert get_subagents_app_config().timeout_seconds == 1800
assert get_tool_search_config().enabled is False
assert get_guardrails_config().enabled is False
assert get_checkpointer_config() is None
assert get_stream_bridge_config() is None
finally:
_reset_config_singletons()
def test_get_app_config_resets_persistence_runtime_singletons_when_checkpointer_removed(tmp_path, monkeypatch):
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
_write_config_with_sections(config_path, {"checkpointer": {"type": "memory"}})
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
reset_checkpointer()
reset_store()
reset_app_config()
try:
get_app_config()
initial_checkpointer = get_checkpointer()
initial_store = get_store()
_write_config_with_sections(config_path)
next_mtime = config_path.stat().st_mtime + 5
os.utime(config_path, (next_mtime, next_mtime))
get_app_config()
assert get_checkpointer_config() is None
assert get_checkpointer() is not initial_checkpointer
assert get_store() is not initial_store
finally:
_reset_config_singletons()
def test_get_app_config_keeps_persistence_runtime_singletons_when_checkpointer_unchanged(tmp_path, monkeypatch):
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
_write_config_with_sections(
config_path,
{
"title": {"enabled": False},
"checkpointer": {"type": "memory"},
},
)
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
_reset_config_singletons()
try:
get_app_config()
initial_checkpointer = get_checkpointer()
initial_store = get_store()
_write_config_with_sections(
config_path,
{
"title": {"enabled": True},
"checkpointer": {"type": "memory"},
},
)
next_mtime = config_path.stat().st_mtime + 5
os.utime(config_path, (next_mtime, next_mtime))
get_app_config()
assert get_checkpointer() is initial_checkpointer
assert get_store() is initial_store
finally:
_reset_config_singletons()
def test_get_app_config_does_not_reset_persistence_singletons_when_database_changes(tmp_path, monkeypatch):
# ``database`` is a restart-required field: the ORM engine is built once at
# startup and never rebuilt on a config.yaml edit. Resetting only the sync
# checkpointer/store singletons on a live ``postgres_schema`` change would
# half-migrate the deployment (new checkpoint/store tables in the new schema,
# ORM rows still in the old one). So a ``database`` change must NOT trigger a
# partial reset -- the operator must restart.
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
_write_config_with_sections(
config_path,
{"database": {"backend": "postgres", "postgres_url": "postgresql://localhost/db", "postgres_schema": "schema_a"}},
)
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
_reset_config_singletons()
reset_calls = {"checkpointer": 0, "store": 0}
def _reset_checkpointer() -> None:
reset_calls["checkpointer"] += 1
def _reset_store() -> None:
reset_calls["store"] += 1
monkeypatch.setattr("deerflow.runtime.checkpointer.reset_checkpointer", _reset_checkpointer)
monkeypatch.setattr("deerflow.runtime.store.reset_store", _reset_store)
try:
get_app_config()
reset_calls["checkpointer"] = 0
reset_calls["store"] = 0
_write_config_with_sections(
config_path,
{"database": {"backend": "postgres", "postgres_url": "postgresql://localhost/db", "postgres_schema": "schema_b"}},
)
next_mtime = config_path.stat().st_mtime + 5
os.utime(config_path, (next_mtime, next_mtime))
get_app_config()
assert get_checkpointer_config() is None
assert reset_calls == {"checkpointer": 0, "store": 0}
finally:
_reset_config_singletons()
def test_get_app_config_does_not_mutate_singletons_when_reload_validation_fails(tmp_path, monkeypatch):
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
_write_config_with_sections(
config_path,
{
"title": {"enabled": False},
"tool_search": {"enabled": True},
"checkpointer": {"type": "memory"},
},
)
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
_reset_config_singletons()
try:
previous_app_config = get_app_config()
initial_checkpointer = get_checkpointer()
initial_store = get_store()
_write_config_with_sections(
config_path,
{
"title": False,
"tool_search": False,
"checkpointer": {"type": "memory"},
},
)
next_mtime = config_path.stat().st_mtime + 5
os.utime(config_path, (next_mtime, next_mtime))
with pytest.raises(ValidationError):
get_app_config()
assert app_config_module._app_config is previous_app_config
assert get_title_config().enabled is False
assert get_tool_search_config().enabled is True
assert get_checkpointer_config() is not None
assert get_checkpointer() is initial_checkpointer
assert get_store() is initial_store
finally:
_reset_config_singletons()
def test_get_memory_config_self_syncs_without_prior_get_app_config(tmp_path, monkeypatch):
"""get_memory_config() triggers reload when file changes without prior get_app_config().
Background memory paths (updater/queue/storage) never call get_app_config()
directly. This test pins the fix: calling get_memory_config() in isolation
— after mutating the file, with no intervening get_app_config() — reflects
the new value.
"""
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
_write_config_with_sections(config_path, {"memory": {"enabled": False}})
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
reset_app_config()
try:
get_app_config()
assert get_memory_config().enabled is False
_write_config_with_sections(config_path, {"memory": {"enabled": True}})
next_mtime = config_path.stat().st_mtime + 5
os.utime(config_path, (next_mtime, next_mtime))
assert get_memory_config().enabled is True
finally:
_reset_config_singletons()
def test_blob_store_follows_config_file_reload(tmp_path, monkeypatch):
"""The storage accessor must see config edits without a prior app-config read."""
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
first_root = tmp_path / "first"
second_root = tmp_path / "second"
_write_config_with_sections(config_path, {"blob_storage": {"enabled": True, "backend_config": {"root": str(first_root)}}})
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
reset_app_config()
reset_blob_store()
try:
get_app_config()
first_store = get_blob_store()
_write_config_with_sections(config_path, {"blob_storage": {"enabled": True, "backend_config": {"root": str(second_root)}}})
second_store = get_blob_store()
assert second_store is not first_store
ref = second_store.put_bytes(b"after reload", kind="tool-output")
assert (second_root / ref.kind / ref.sha256[:2] / ref.sha256).is_file()
assert not (first_root / ref.kind / ref.sha256[:2] / ref.sha256).exists()
_write_config_with_sections(config_path, {"blob_storage": {"enabled": False}})
assert get_blob_store_if_enabled() is None
with pytest.raises(BlobNotConfiguredError):
get_blob_store()
finally:
reset_blob_store()
_reset_config_singletons()
def test_get_memory_config_falls_back_on_broken_config(tmp_path, monkeypatch):
"""get_memory_config() does not crash on transiently broken config.yaml.
get_app_config() can raise yaml.YAMLError, ValidationError, or ValueError.
get_memory_config() catches them and returns the last-good singleton.
"""
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
_write_config_with_sections(config_path, {"memory": {"enabled": False}})
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
reset_app_config()
try:
get_app_config()
assert get_memory_config().enabled is False
config_path.write_text("memory: {enabled: true\n")
next_mtime = config_path.stat().st_mtime + 5
os.utime(config_path, (next_mtime, next_mtime))
assert get_memory_config().enabled is False
finally:
_reset_config_singletons()
# The two judging modes as an operator writes them. `mode: off` is spelled the way
# config.example.yaml ships it and the way a rollback types it.
_MODES_CONFIG = """\
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider
models:
- name: first-model
use: langchain_openai:ChatOpenAI
model: gpt-test
memory:
prescreen:
mode: {prescreen}
use: deerflow.agents.memory.prescreen.typesafe:TypeSafeMemoryPrescreen
signal_classification:
mode: {classification}
use: deerflow.agents.memory.signals.typesafe:TypeSafeSignalClassifier
"""
def test_an_unquoted_off_mode_loads_and_rolls_back(tmp_path, monkeypatch):
"""`mode: off` is a YAML boolean, and both judging modes must still read it as off.
YAML 1.1 parses an unquoted ``off`` as ``False``. Rejecting it would fail the
config *reload* that returns a deployment to off -- the failed load leaves the
previous judge (and its judging requests) running -- so the documented spelling
has to resolve to the ``off`` mode, not raise.
"""
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path)
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
def write_modes(prescreen: str, classification: str) -> None:
config_path.write_text(_MODES_CONFIG.format(prescreen=prescreen, classification=classification), encoding="utf-8")
try:
write_modes("enforce", "hints")
enforcing = AppConfig.from_file(str(config_path))
assert enforcing.memory.prescreen.mode == "enforce"
assert enforcing.memory.signal_classification.mode == "hints"
write_modes("off", "off")
rolled_back = AppConfig.from_file(str(config_path))
assert rolled_back.memory.prescreen.mode == "off"
assert rolled_back.memory.signal_classification.mode == "off"
finally:
_reset_config_singletons()
def test_get_app_config_refreshes_extensions_snapshot_after_peer_extensions_write(tmp_path, monkeypatch):
extensions_path = _stage_singleton_configs(tmp_path, monkeypatch, middlewares=["pkg.first:FirstMiddleware"])
try:
initial = get_app_config()
loaded = peek_loaded_app_config()
yaml_signature = app_config_module._app_config_signature
assert initial.extensions.middlewares == ["pkg.first:FirstMiddleware"]
get_app_config()
assert peek_loaded_app_config() is loaded, "neither file changed: no reload"
_peer_writes_extensions(extensions_path, middlewares=["pkg.second:SecondMiddleware"], skills={"demo": {"enabled": False}})
refreshed = get_app_config()
assert refreshed.extensions.middlewares == ["pkg.second:SecondMiddleware"]
assert refreshed.extensions.skills["demo"].enabled is False
assert peek_loaded_app_config() is not loaded
assert app_config_module._app_config_signature == yaml_signature, "config.yaml itself was not re-signed"
finally:
_reset_config_singletons()
def test_get_app_config_keeps_extensions_snapshot_while_extensions_file_is_half_written(tmp_path, monkeypatch):
extensions_path = _stage_singleton_configs(tmp_path, monkeypatch, middlewares=["pkg.first:FirstMiddleware"])
try:
get_app_config()
loaded = peek_loaded_app_config()
# The non-atomic overwrite fallback can expose a truncated document.
extensions_path.write_text('{"mcpServers": {', encoding="utf-8")
assert get_app_config().extensions.middlewares == ["pkg.first:FirstMiddleware"]
assert peek_loaded_app_config() is loaded
_peer_writes_extensions(extensions_path, middlewares=["pkg.second:SecondMiddleware"])
assert get_app_config().extensions.middlewares == ["pkg.second:SecondMiddleware"]
finally:
_reset_config_singletons()
def test_get_app_config_keeps_extensions_snapshot_when_search_file_disappears_during_reload(tmp_path, monkeypatch):
extensions_path = _stage_singleton_configs(tmp_path, monkeypatch, middlewares=["pkg.first:FirstMiddleware"])
monkeypatch.delenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH")
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path))
monkeypatch.setattr(extensions_config_module, "__file__", str(tmp_path / "isolated/backend/packages/harness/deerflow/config/extensions_config.py"))
try:
initial = get_app_config()
loaded = peek_loaded_app_config()
yaml_signature = app_config_module._app_config_signature
assert initial.extensions.middlewares == ["pkg.first:FirstMiddleware"]
_peer_writes_extensions(extensions_path, middlewares=["pkg.second:SecondMiddleware"])
original_signature = extensions_config_module.get_config_signature
def signature_with_peer_deletion(path: Path):
assert path == extensions_path
signature = original_signature(path)
path.unlink()
return signature
with monkeypatch.context() as race:
race.setattr(extensions_config_module, "get_config_signature", signature_with_peer_deletion)
assert get_app_config().extensions.middlewares == ["pkg.first:FirstMiddleware"]
assert peek_loaded_app_config() is loaded
assert app_config_module._app_config_signature == yaml_signature
assert get_app_config().extensions.middlewares == ["pkg.first:FirstMiddleware"]
_peer_writes_extensions(extensions_path, middlewares=["pkg.second:SecondMiddleware"])
assert get_app_config().extensions.middlewares == ["pkg.second:SecondMiddleware"]
assert peek_loaded_app_config() is not loaded
assert app_config_module._app_config_signature == yaml_signature
finally:
_reset_config_singletons()
def test_get_app_config_follows_writer_reload_and_injected_extensions_config(tmp_path, monkeypatch):
extensions_path = _stage_singleton_configs(tmp_path, monkeypatch, middlewares=["pkg.first:FirstMiddleware"])
try:
assert get_app_config().extensions.middlewares == ["pkg.first:FirstMiddleware"]
# A writer in this process reloads after its own write.
_peer_writes_extensions(extensions_path, middlewares=["pkg.second:SecondMiddleware"])
reload_extensions_config()
assert get_app_config().extensions.middlewares == ["pkg.second:SecondMiddleware"]
# An injected instance is pinned, and the AppConfig snapshot follows it.
set_extensions_config(ExtensionsConfig(middlewares=["pkg.injected:InjectedMiddleware"]))
assert get_app_config().extensions.middlewares == ["pkg.injected:InjectedMiddleware"]
finally:
_reset_config_singletons()
def test_get_app_config_keeps_config_yaml_extension_override_after_extensions_reload(tmp_path, monkeypatch):
extensions_path = _stage_singleton_configs(tmp_path, monkeypatch, middlewares=["pkg.first:FirstMiddleware"])
config_path = tmp_path / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"models": [{"name": "model-a", "use": "langchain_openai:ChatOpenAI", "model": "gpt-test"}],
"extensions": {"middlewares": ["pkg.from_yaml:YamlMiddleware"]},
}
),
encoding="utf-8",
)
try:
assert get_app_config().extensions.middlewares == ["pkg.from_yaml:YamlMiddleware"]
_peer_writes_extensions(extensions_path, middlewares=["pkg.second:SecondMiddleware"])
assert get_app_config().extensions.middlewares == ["pkg.from_yaml:YamlMiddleware"]
finally:
_reset_config_singletons()