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>
1110 lines
43 KiB
Python
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()
|