fix(config): sync _memory_config with AppConfig auto-reload (#4208)

* fix(config): sync _memory_config with AppConfig auto-reload

get_memory_config() now calls get_app_config() before returning the
cached _memory_config singleton. get_app_config() auto-reloads when
the config file signature changes, and its _apply_singleton_configs()
calls load_memory_config_from_dict() to populate _memory_config.
Without this side effect, changing memory.mode in config.yaml (e.g.
from "middleware" to "tool") would never take effect at runtime
because _memory_config retained the stale value.

Only call get_app_config() when _app_config has already been loaded
to avoid picking up a config file as a side effect of the first
access to get_memory_config(), which would break callers that expect
module-level defaults (e.g. unit tests). Catch FileNotFoundError for
environments without a config file.

Closes #4204.

* fix(memory): broaden config-reload guard and add isolated-path tests

Addresses two review findings from willem-bd:

1. Broken-config crash risk: broaden the except from FileNotFoundError
   to Exception so transiently broken config.yaml (YAMLError,
   ValidationError, ValueError) falls back to the last-good singleton
   instead of crashing per-turn hot paths (memory_middleware,
   summarization_hook, lead_agent/prompt.py).

2. Missing regression test: add test_get_memory_config_self_syncs_...
   (calls get_memory_config() after file mutation with no intervening
   get_app_config() call) and test_get_memory_config_falls_back_on_...
   (verifies broken config does not crash, returns cached value).
This commit is contained in:
qin-chenghan 2026-07-21 10:14:29 +08:00 committed by GitHub
parent 8153e68eb8
commit 9eebc6a9e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 90 additions and 1 deletions

View File

@ -121,7 +121,36 @@ _memory_config: MemoryConfig = MemoryConfig()
def get_memory_config() -> MemoryConfig:
"""Get the current memory configuration."""
"""Get the current memory configuration.
``_memory_config`` is only refreshed as a side effect of ``get_app_config()``
reloading (via ``_apply_singleton_configs`` -> ``load_memory_config_from_dict``).
A reader that reaches memory config without going through ``get_app_config()``
first -- e.g. the agent factory deciding whether to bind the memory tools --
would otherwise see a stale ``memory.mode`` after a ``config.yaml`` edit, even
though ``memory.*`` is documented as hot-reloadable. Trigger the same
signature-checked reload here so the singleton follows the config file.
If ``get_app_config()`` has never been called (``_app_config`` is ``None``),
there is no stale config to refresh, so we keep the pre-existing behaviour
of returning the in-memory singleton. This avoids picking up a config file
as a side effect of the first access to ``get_memory_config()``, which would
break callers that expect module-level defaults (e.g. unit tests).
"""
# Lazy import: app_config imports this module, so a top-level import cycles.
from .app_config import _app_config, get_app_config
if _app_config is not None:
try:
get_app_config()
except Exception:
# If the config file is transiently broken (invalid YAML, schema
# violation, missing env var, etc.), keep the last-good singleton
# so an in-flight turn completes normally instead of crashing.
logger.warning(
"Failed to reload app config from get_memory_config(); falling back to cached memory config.",
exc_info=True,
)
return _memory_config

View File

@ -611,3 +611,63 @@ def test_get_app_config_does_not_mutate_singletons_when_reload_validation_fails(
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_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()