diff --git a/README.md b/README.md index ad77975..d54a021 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ make docker-start # Start services (auto-detects sandbox mode from config.yaml ``` `make docker-start` starts `provisioner` only when `config.yaml` uses provisioner mode (`sandbox.use: deerflow.community.aio_sandbox:AioSandboxProvider` with `provisioner_url`). +Backend processes automatically pick up `config.yaml` changes on the next config access, so model metadata updates do not require a manual restart during development. **Production** (builds images locally, mounts runtime config and data): diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 22b77d2..afe4a03 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -172,6 +172,8 @@ Setup: Copy `config.example.yaml` to `config.yaml` in the **project root** direc **Config Versioning**: `config.example.yaml` has a `config_version` field. On startup, `AppConfig.from_file()` compares user version vs example version and emits a warning if outdated. Missing `config_version` = version 0. Run `make config-upgrade` to auto-merge missing fields. When changing the config schema, bump `config_version` in `config.example.yaml`. +**Config Caching**: `get_app_config()` caches the parsed config, but automatically reloads it when the resolved config path changes or the file's mtime increases. This keeps Gateway and LangGraph reads aligned with `config.yaml` edits without requiring a manual process restart. + Configuration priority: 1. Explicit `config_path` argument 2. `DEER_FLOW_CONFIG_PATH` environment variable diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index 0f73c35..fc48d1e 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -224,17 +224,65 @@ class AppConfig(BaseModel): _app_config: AppConfig | None = None +_app_config_path: Path | None = None +_app_config_mtime: float | None = None +_app_config_is_custom = False + + +def _get_config_mtime(config_path: Path) -> float | None: + """Get the modification time of a config file if it exists.""" + try: + return config_path.stat().st_mtime + except OSError: + return None + + +def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig: + """Load config from disk and refresh cache metadata.""" + global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom + + resolved_path = AppConfig.resolve_config_path(config_path) + _app_config = AppConfig.from_file(str(resolved_path)) + _app_config_path = resolved_path + _app_config_mtime = _get_config_mtime(resolved_path) + _app_config_is_custom = False + return _app_config def get_app_config() -> AppConfig: """Get the DeerFlow config instance. - Returns a cached singleton instance. Use `reload_app_config()` to reload - from file, or `reset_app_config()` to clear the cache. + Returns a cached singleton instance and automatically reloads it when the + underlying config file path or modification time changes. Use + `reload_app_config()` to force a reload, or `reset_app_config()` to clear + the cache. """ - global _app_config - if _app_config is None: - _app_config = AppConfig.from_file() + global _app_config, _app_config_path, _app_config_mtime + + if _app_config is not None and _app_config_is_custom: + return _app_config + + resolved_path = AppConfig.resolve_config_path() + current_mtime = _get_config_mtime(resolved_path) + + should_reload = ( + _app_config is None + or _app_config_path != resolved_path + or _app_config_mtime != current_mtime + ) + if should_reload: + if ( + _app_config_path == resolved_path + and _app_config_mtime is not None + and current_mtime is not None + and _app_config_mtime != current_mtime + ): + logger.info( + "Config file has been modified (mtime: %s -> %s), reloading AppConfig", + _app_config_mtime, + current_mtime, + ) + _load_and_cache_app_config(str(resolved_path)) return _app_config @@ -251,9 +299,7 @@ def reload_app_config(config_path: str | None = None) -> AppConfig: Returns: The newly loaded AppConfig instance. """ - global _app_config - _app_config = AppConfig.from_file(config_path) - return _app_config + return _load_and_cache_app_config(config_path) def reset_app_config() -> None: @@ -263,8 +309,11 @@ def reset_app_config() -> None: `get_app_config()` to reload from file. Useful for testing or when switching between different configurations. """ - global _app_config + global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom _app_config = None + _app_config_path = None + _app_config_mtime = None + _app_config_is_custom = False def set_app_config(config: AppConfig) -> None: @@ -275,5 +324,8 @@ def set_app_config(config: AppConfig) -> None: Args: config: The AppConfig instance to use. """ - global _app_config + global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom _app_config = config + _app_config_path = None + _app_config_mtime = None + _app_config_is_custom = True diff --git a/backend/tests/test_app_config_reload.py b/backend/tests/test_app_config_reload.py new file mode 100644 index 0000000..716d744 --- /dev/null +++ b/backend/tests/test_app_config_reload.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + +import yaml + +from deerflow.config.app_config import get_app_config, 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_extensions_config(path: Path) -> None: + path.write_text(json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8") + + +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_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()